diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 85b471448..bc6ec4f7a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -35,7 +35,7 @@ mention the rationale here. ## PR Completion Checklist -- [ ] Reviewed & followed the [Contributor Guidelines](https://python-semantic-release.readthedocs.io/en/latest/contributing.html) +- [ ] Reviewed & followed the [Contributor Guidelines](https://python-semantic-release.readthedocs.io/en/stable/contributing/contributing_guide.html) - [ ] Changes Implemented & Validation pipeline succeeds diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..97a92c06a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,550 @@ +# Copilot Instructions for python-semantic-release + +This document explains how GitHub Copilot-like automated agents should interact with +the python-semantic-release repository. + +## Project Overview + +Python Semantic Release is a tool for automating semantic versioning and marking releases for +various types of software projects. It analyzes commit messages with various commit parsers +(the most notable being the Conventional Commits specification) to determine what the next +version should be and facilitates release steps that the developer generally has to do. This +includes generating changelogs, stamping the code with version strings, creating a repository +tag and annotating releases on a remote Git server with version-specific release notes. + +**Key Components:** +- **CLI**: Command-line interface for version management, changelog generation, and publishing +- **Commit Parsers**: Parse commit messages to determine version bumps + (Supports Conventional-Commits, Emoji, and Scipy format) +- **HVCS Integration**: Integrations with GitHub, GitLab, Gitea, and Bitbucket for releasing +- **Version Management**: Semantic versioning logic and version calculation +- **Changelog Generation**: Automated and customizable changelog creation using Jinja2 templates + +## Development Setup + +### Installation + +Requires 3.9+ for development dependencies, but runtime supports 3.8+. + +```bash +# Set up for development +pip install -e .[build,dev,docs,mypy,test] +``` + +### Running the Application + +```bash +# See the CLI help +semantic-release --help + +# Common commands +semantic-release version +semantic-release changelog +semantic-release publish +``` + +### Making Changes + +Minimal PR checklist (run locally before proposing a PR): + +- [ ] Run pre-PR checklist script (see below) +- [ ] If you added dependencies: update `pyproject.toml` and mention them in the PR. +- [ ] Review the helpful tips at the bottom of this document to ensure best practices. +- [ ] Verify that commit messages follow the Commit Message Conventions section below. + +Runnable pre-PR checklist script (copyable): + +```bash +# lint & format +ruff format . +ruff check --unsafe-fixes . +# run type checks +mypy . +# run unit tests +pytest -m unit +# run e2e tests +pytest -m e2e +# optional docs build when docs changed +sphinx-build -b html docs docs/_build/html +``` + +## Code Style and Quality + +### Linting and Formatting + +- **Ruff**: Primary linter and formatter (replaces Black, isort, flake8) + + ```bash + # run check for lint errors + ruff check --unsafe-fixes . + + # apply lint fixes + ruff check --unsafe-fixes --fix . + + # check for formatting issues + ruff format --check . + + # apply formatting fixes + ruff format . + ``` + +- **Type Checking**: Use mypy for type checking + + ```bash + mypy . + ``` + +### Code Style Guidelines + +1. **Type Hints**: All functions must have complete type hints (enforced by mypy) + +2. **Docstrings**: Use sphinx-style docstrings (though currently many are missing - add + only when modifying a function or adding new code) + +3. **Line Length**: 88 characters (enforced by Ruff) + +4. **Import Style**: + + - Absolute imports only (no relative imports) + - All files must use `from __future__ import annotations` for ignoring type hints at runtime + - Prefer `from module import Class` over `import module` when using classes/functions + - Running Ruff with `--unsafe-fixes` and `--fix` will automatically sort and group imports + - All files should have a `if TYPE_CHECKING: # pragma: no cover` block for type-only imports + - Prefer renaming `re` imports for clarity (e.g. `from re import compile as regexp, escape as regexp_escape`) + +5. **String Quotes**: Use double quotes for strings + +6. **Error Handling**: Create specific exception classes inheriting from `SemanticReleaseBaseError` + and defined in `errors.py` + +### Common Patterns + +- Configuration uses Pydantic models (v2) for validation +- CLI uses Click framework with click-option-group for organization +- Git operations use GitPython library +- Templating uses Jinja2 for changelogs and release notes + +## Testing + +### Test Structure + +- **Unit Tests**: `tests/unit/` - Fast, isolated tests + +- **E2E Tests**: `tests/e2e/` - End-to-end integration tests performed on real git repos + (as little mocking as possible, external network calls to HVCS should be mocked). Repos are + cached into `.pytest_cache/` for faster test setup/runs after the first build. E2E tests are + built to exercise the CLI commands and options against real git repositories with various commit + histories and configurations. + +- **Fixtures**: `tests/fixtures/` - Reusable test data and fixtures + +- **Repository Fixtures**: `tests/fixtures/repos/` - Example git repositories for testing and rely on + `tests/fixtures/example_project.py` and `tests/fixtures/git_repo.py` for setup + +- **Monorepo Fixtures**: `tests/fixtures/monorepo/` - Example monorepos for testing monorepo support + +- **GitHub Action Tests**: `tests/gh_action/` - Tests for simulating GitHub Docker Action functionality + +### Running Tests + +```bash +# Run only unit tests +pytest -m unit + +# Run only e2e tests +pytest -m e2e + +# Run comprehensive (unit & e2e) test suite with full verbosity (all variations of repositories) +# Warning: long runtime (14mins+) only necessary for testing all git repository variations +pytest -vv --comprehensive + +# Run GitHub Docker Action tests (requires Docker, see .github/workflows/validate.yml for setup) +# Only required when modifying the GitHub Action code (src/gh_action/, and action.yml) +bash tests/gh_action/run.sh +``` + +### Testing Guidelines + +1. **Test Organization**: + + - Group unit tests by module structure mirroring `src/` under `tests/unit/` + - Group e2e tests by CLI command under `tests/e2e/` + - Use descriptive test function names that clearly indicate the scenario being tested + - Test docstrings should follow the format: `"""Given , when , then ."""` + +2. **Fixtures**: Use pytest fixtures from `tests/conftest.py` and `tests/fixtures/` + +3. **Markers**: Apply appropriate markers (`@pytest.mark.unit`, `@pytest.mark.e2e`, `@pytest.mark.comprehensive`) + +4. **Mocking**: Use `pytest-mock` for mocking, `responses` for HTTP mocking + +5. **Parametrization**: Use `@pytest.mark.parametrize` for testing multiple scenarios + +6. **Test Data**: Use `tests/fixtures/repos/` for specific git repository workflow strategies + + - Git repository strategies include: + + - Git Flow: + - branch & merge commit strategy + - varying number of branches & release branches + + - GitHub Flow: + - squash merge strategy + - branch & merge commit strategy + - varying number of release branches & simulated simultaneous work branches + - varying branch update strategies (e.g. rebasing, merging) + + - Trunk-Based Development (no branches): + - unreleased repo (no tags) + - trunk with only official release tags + - trunk with mixed release and pre-release tags + - concurrent major version support + + - ReleaseFlow (Not supported yet) + + - Monorepo (multiple packages): + - trunk based development with only official release tags + - github flow with squash merge strategy + - github flow with branch & merge commit strategy + +### Test Coverage + +- Maintain high test coverage for core functionality +- Unit tests should be fast and not touch filesystem/network when possible +- E2E tests should test realistic workflows with actual git operations + +### Pull Request testing + +Each PR will be evaluated through an GitHub Actions workflow before it can be merged. +The workflow is very specialized to run the tests in a specific order and with specific +parameters. Please refer to `.github/workflows/ci.yml` for details on how the tests are +structured and run. + +## Commit Message Conventions + +This project uses **Conventional Commits** specification and is versioned by itself. See +the `CHANGELOG.rst` for reference of how the conventional commits and specific rules this +project uses are used in practice to communicate changes to users. + +It is highly important to separate the code changes into their respective commit types +and scopes to ensure that the changelog is generated correctly and that users can +understand the changes in each release. The commit message format is strictly enforced +and should be followed for all commits. + +When submitting a pull request, it is recommended to commit any end-2-end test cases +first as a `test` type commit, then the implementation changes as `feat`, `fix`, etc. +This order allows reviewers to run the test which demonstrates the failure case before +validating the implementation changes by doing a `git merge origin/` to run the +test again and see it pass. Unit test cases will need to be committed after the source +code implementation changes as they will not run without the implementation code. +Documentation changes should be committed last and the commit scope should be a short +reference to the page its modifying (e.g. `docs(github-actions): ` or +`docs(configuration): `). Commit types should be chosen based on reference +to the default branch as opposed to its previous commits on the branch. For example, if +you are fixing a bug in a feature that was added in the same branch, the commit type +should be `refactor` instead of `fix` since the bug was introduced in the same branch +and is not present in the default branch. + +### Format + +``` +(): + + + +[optional footer(s)] +``` + +Scopes by the specification are optional but for this project, they are required and +only by exception can they be omitted. + +Footers include: + +- `BREAKING CHANGE: ` for breaking changes + +- `NOTICE: ` for additional release information that should be included + in the changelog to give users more context about the release + +- `Resolves: #` for linking to bug fixes. Use `Implements: #` + for new features. + +You should not have a breaking change and a notice in the same commit. If you have a +breaking change, the breaking change description should include all relevant information +about the change and how to update. + +### Types + +- `feat`: New feature (minor version bump) +- `fix`: Bug fix (patch version bump) +- `perf`: Performance improvement (patch version bump) +- `docs`: Documentation only changes +- `style`: Code style changes (formatting, missing semicolons, etc.) +- `refactor`: Code refactoring without feature changes or bug fixes +- `test`: Adding or updating tests +- `build`: Changes to build system or dependencies +- `ci`: Changes to CI configuration +- `chore`: Other changes that don't modify src or test files + +### Breaking Changes + +- Add `!` after the scope: `feat(scope)!: breaking change` and add + `BREAKING CHANGE:` in footer with detailed description of what was changed, + why, and how to update. + +### Notices + +- Add `NOTICE: ` in footer to include important information about the + release that should be included in the changelog. This is for things that require + more explanation than a simple commit message and are not breaking changes. + +### Scopes + +Use scopes as categories to indicate the area of change. They are most important for the +types of changes that are included in the changelog (bug fixes, features, performance +improvements, documentation, build dependencies) to tell the user what area was changed. + +Common scopes include: + +- `changelog`: Changes related to changelog generation +- `config`: Changes related to user configuration +- `fixtures`: Changes related to test fixtures +- `deps`: Changes related to runtime dependencies +- `deps-dev`: Changes related to development dependencies + (as defined in `pyproject.toml:project.optional-dependencies.dev`) +- `deps-build`: Changes related to build dependencies + (as defined in `pyproject.toml:project.optional-dependencies.build`) +- `deps-docs`: Changes related to documentation dependencies + (as defined in `pyproject.toml:project.optional-dependencies.docs`) +- `deps-test`: Changes related to testing dependencies + (as defined in `pyproject.toml:project.optional-dependencies.test`) + +We use hyphenated scopes to group related changes together in a category to subcategory +format. The most common categories are: + +- `cmd-`: Changes related to a specific CLI command +- `parser-`: Changes related to a specific commit parser +- `hvcs-`: Changes related to a specific hosting service integration + +## Architecture + +The project's primary entrypoint is `src/semantic_release/__main__.py:main`, as defined +in `pyproject.toml:project.scripts`. This is the CLI interface that users interact with. +The CLI is built using Click and lazy-loaded subcommands for version management, +changelog generation, and publishing. + +Although the project is primarily a CLI tool, the code is under development to become +more modular and pluggable to allow for more flexible usage in other contexts (e.g. as a +library). + +This repository also is provided as a GitHub Action (see `src/gh_action/`) for users +who want a pre-built solution for their GitHub repositories. The action is built using Docker +and wraps the built wheel of the project before it runs the CLI version command in a +containerized environment. The publish command is also available as a GitHub Action but +that code is hosted in a separate repository (https://github.com/python-semantic-release/publish-action). + +### Key Components + +- `src/semantic_release/cli/`: Click-based CLI interface + - `commands/`: Individual CLI commands (version, changelog, publish) + - `config.py`: Configuration loading and validation with Pydantic + +- `src/semantic_release/commit_parser/`: Commit message parsers + - `_base.py`: Base parser interface + - `conventional/parser.py`: Conventional Commits parser + - `conventional/options.py`: Conventional Commits parser options + - `conventional/parser_monorepo.py`: Conventional Commits parser for monorepos + - `conventional/options_monorepo.py`: Conventional Commits monorepo parser options + - `angular.py`, `emoji.py`, `scipy.py`, `tag.py`: Parser implementations + +- `src/semantic_release/hvcs/`: Hosting service integrations + - `_base.py`: Base HVCS interface + - `remote_hvcs_base.py`: Base class for remote HVCS implementations + - `github.py`, `gitlab.py`, `gitea.py`, `bitbucket.py`: Service implementations + +- `src/semantic_release/version/`: Version management + - `version.py`: Version class and comparison logic + - `declarations/`: Implementations of how to stamp versions into various types of code + from users' configuration + - `translator.py`: Version translation between different formats + +- `src/gh_action/`: GitHub Docker Action implementation + - `action.sh`: Main entrypoint for the action + - `Dockerfile`: Dockerfile for the action + +- `action.yml`: GitHub Action definition + +### Design Patterns + +- **Strategy Pattern**: Commit parsers and HVCS implementations are pluggable +- **Template Method**: Base classes define workflow, subclasses implement specifics +- **Builder Pattern**: Version calculation builds up changes from commits +- **Factory Pattern**: Parser and HVCS selection based on configuration +- **Composition Pattern**: The future of the project's design for pluggable components + +## Building and Releasing + +### Local Build + +```bash +pip install -e .[build] +bash scripts/build.sh +``` + +### Release Process + +This project is released via GitHub Actions (see `.github/workflows/cicd.yml`) after +a successful validation workflow. During release, it runs a previous version of +itself to perform the release steps. The release process includes: + +1. Commits are analyzed from the last tag that exists on the current branch +2. Version is determined based on commit types +3. Changelog is generated from commits +4. Source code is stamped with new version +5. Documentation is stamped with the new version (`$NEW_VERSION`) + or new release tag (`$NEW_RELEASE_TAG`) (see `scripts/bump_version_in_docs.py` for details) +6. Package is built with stamped version +7. Code changes from steps 4-6 are committed and pushed to the repository +8. A new tag is created for the release and pushed to the repository +9. A new release is created on the hosting service with version-specific generated release notes +10. Assets are uploaded to the release +11. The package is published to PyPI +12. ReadTheDocs is triggered to build & publish the documentation + +### Version Configuration + +- Version stored in `pyproject.toml:project.version` +- Additional version locations in `tool.semantic_release.version_variables` +- Follows semantic versioning: MAJOR.MINOR.PATCH + +## Common Tasks + +### Adding a New Commit Parser + +1. Create new parser in `src/semantic_release/commit_parser/` +2. Inherit from `CommitParser` base class +3. Implement `parse()` method +4. Add parser to `KNOWN_COMMIT_PARSERS` in config +5. Add tests in `tests/unit/semantic_release/commit_parser/` +6. Add fixtures that can select the new parser for e2e tests + +### Adding a New HVCS Integration + +1. Create new HVCS in `src/semantic_release/hvcs/` +2. Inherit from `HvcsBase` base class or `RemoteHvcsBase` if it is a remote service +3. Implement required methods (token creation, asset upload, release creation) +4. Add HVCS to configuration options +5. Add tests in `tests/unit/semantic_release/hvcs/` +6. Add fixtures that can select the new HVCS for e2e tests + +### Adding a New CLI Command + +1. Create command in `src/semantic_release/cli/commands/` +2. Use Click decorators for arguments/options +3. Access shared context via `ctx.obj` (RuntimeContext) +4. Add command to main command group in `src/semantic_release/cli/commands/main.py` +5. Add tests in `tests/e2e/cmd_/` +6. Add documentation for the command in `docs/api/commands.rst` + +### Modifying the included default changelog templates source + +1. Update the default templates in `src/semantic_release/data/templates///` +2. Update the fixtures in `tests/fixtures/git_repo.py` to correctly replicate the + format of the new templates via code. +3. Update the unit tests for changelog generation + +### Adding a new configuration option + +1. Update the Pydantic models in `cli/config.py` with validation +2. Add option over into the RuntimeContext if necessary +3. Add option description to documentation in + `docs/configuration/configuration.rst` +4. Add unit tests for the validation of the option in `tests/unit/semantic_release/cli/config.py` +5. Add e2e tests for the option in `tests/e2e/` depending on the option's scope + and functionality. + +### Adding a new command line option + +1. Add the option to the appropriate CLI command in + `src/semantic_release/cli/commands/` +2. Add the option to the GitHub Action if it is for the `version` command +3. Add the option to the documentation in `docs/api/commands.rst` +4. Add the option to the GitHub Action documentation in + `docs/configuration/automatic-releases/github-actions.rst` +5. Add e2e tests for the option in `tests/e2e/cmd_/` + +### Adding a new changelog context filter + +1. Implement the filter in `src/semantic_release/changelog/context.py` +2. Add the filter to the changelog context and release notes context objects +3. Add unit tests for the filter in `tests/unit/semantic_release/changelog/**` +4. Add description and example of how to use the filter in the documentation + in `docs/concepts/changelog_templates.rst` + +## Important Files + +- `pyproject.toml`: Project configuration, dependencies, tool settings +- `action.yml`: GitHub Action definition +- `config/release-templates/`: Project-specific Jinja2 templates for changelog and release notes +- `.pre-commit-config.yaml`: Pre-commit hooks configuration +- `.readthedocs.yml`: ReadTheDocs configuration +- `CONTRIBUTING.rst`: Contribution guidelines + +## Documentation + +- Hosted on ReadTheDocs: https://python-semantic-release.readthedocs.io +- Source in `docs/` directory +- Uses Sphinx with Furo theme +- Build locally: `sphinx-build -b html docs docs/_build/html` +- View locally: open `docs/_build/html/index.html` + +## Python Version Support + +- Runtime Minimum: Python 3.8 +- Development Dependencies: Python 3.9+ +- Tested on: Python 3.8, 3.14 +- Target version for type checking: Python 3.8 + +## Dependencies to Know + +- **Click**: CLI framework +- **GitPython**: Git operations +- **Pydantic v2**: Configuration validation and models +- **Jinja2**: Template engine for changelogs +- **requests**: HTTP client for HVCS APIs +- **python-gitlab**: GitLab API client +- **tomlkit**: TOML parsing with formatting preservation +- **rich**: Rich terminal output + +## Helpful Tips + +- Never add real secrets, tokens, or credentials to source, commits, fixtures, or logs. + +- All proposed changes must include tests (unit and/or e2e as appropriate) and pass the + local quality gate before creating a PR. + +- When modifying configuration, update the Pydantic models in `cli/config.py` + +- Jinja2 changelog templates for this project are in `config/release-templates/`, whereas + the default changelog templates provided to users as a part of this project are in + `src/semantic_release/data/templates///**`. + +- The `RuntimeContext` object holds shared state across CLI commands + +- Use `--noop` flag to test commands without making changes + +- Version detection respects git tags - use annotated tags + +- The project uses its own tool for versioning, so commit messages matter! + +- When creating a Pull Request, create a PR description that fills out the + PR template found in `.github/PULL_REQUEST_TEMPLATE.md`. This will help + reviewers understand the changes and the impact of the PR. + +- If creating an issue, fill out one of the issue templates found in + `.github/ISSUE_TEMPLATE/` related to the type of issue (bug, feature request, etc.). + This will help maintainers understand the issue and its impact. + +- When adding new features, consider how they will affect the changelog and + versioning. Make as few breaking changes as possible by adding backwards compatibility + and if you do make a breaking change, be sure to include a detailed description in the + `BREAKING CHANGE` footer of the commit message. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4576fc45d..1d7930dfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,16 +16,18 @@ concurrency: jobs: - commitlint: + lint-commits: # condition: Execute IFF it is protected branch update, or a PR that is NOT in a draft state if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 + - name: Lint | Commit Messages + uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 eval-changes: @@ -35,19 +37,20 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - fetch-depth: 100 + fetch-depth: 0 - name: Evaluate | Check common file types for changes id: core-changed-files - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 #v47.0.1 with: files_yaml_from_source_file: .github/changed-files-spec.yml - name: Evaluate | Check specific file types for changes id: ci-changed-files - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 #v47.0.1 with: files_yaml: | ci: @@ -87,7 +90,7 @@ jobs: # It was a bit of overkill before testing every minor version, and since this project is all about # SemVer, we should expect Python to adhere to that model to. Therefore Only test across 2 OS's but # the lowest supported minor version and the latest stable minor version (just in case). - python-versions-linux: '["3.8", "3.13"]' + python-versions-linux: '["3.8", "3.14"]' # Since the test suite takes ~4 minutes to complete on windows, and windows is billed higher # we are only going to run it on the oldest version of python we support. The older version # will be the most likely area to fail as newer minor versions maintain compatibility. diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 6c7ff663d..b1696d9b6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -19,21 +19,21 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - fetch-depth: 100 # Must at least retrieve a set of commits to compare changes - # primarily because of any 'Rebase and Merge' PR action in GitHub + fetch-depth: 0 - name: Evaluate | Check common file types for changes id: core-changed-files - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 #v47.0.1 with: base_sha: ${{ github.event.push.before }} files_yaml_from_source_file: .github/changed-files-spec.yml - name: Evaluate | Check specific file types for changes id: ci-changed-files - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 #v47.0.1 with: base_sha: ${{ github.event.push.before }} files_yaml: | @@ -76,8 +76,8 @@ jobs: # It was a bit of overkill before testing every minor version, and since this project is all about # SemVer, we should expect Python to adhere to that model to. Therefore Only test across 2 OS's but # the lowest supported minor version and the latest stable minor version. - python-versions-linux: '["3.8", "3.13"]' - python-versions-windows: '["3.8", "3.13"]' + python-versions-linux: '["3.8", "3.14"]' + python-versions-windows: '["3.8", "3.14"]' files-changed: ${{ needs.eval-changes.outputs.any-file-changes }} build-files-changed: ${{ needs.eval-changes.outputs.build-changes }} ci-files-changed: ${{ needs.eval-changes.outputs.ci-changes }} @@ -115,7 +115,7 @@ jobs: # possible that the branch was updated while the workflow was running. This # prevents accidentally releasing un-evaluated changes. - name: Setup | Checkout Repository on Release Branch - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.ref_name }} fetch-depth: 0 @@ -125,7 +125,7 @@ jobs: git reset --hard ${{ github.sha }} - name: Setup | Download Build Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 id: artifact-download with: name: ${{ needs.validate.outputs.distribution-artifacts }} @@ -140,49 +140,21 @@ jobs: python -m scripts.bump_version_in_docs git add docs/* - - name: Evaluate | Verify upstream has NOT changed - # Last chance to abort before causing an error as another PR/push was applied to the upstream branch - # while this workflow was running. This is important because we are committing a version change - shell: bash - run: bash .github/workflows/verify_upstream.sh - - name: Release | Python Semantic Release id: release - uses: python-semantic-release/python-semantic-release@2896129e02bb7809d2cf0c1b8e9e795ee27acbcf # v10.2.0 + uses: python-semantic-release/python-semantic-release@02f2a5c74dbb6aa2989f10fc4af12cd8e6bf025f # v10.5.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} verbosity: 1 build: false - name: Release | Add distribution artifacts to GitHub Release Assets - uses: python-semantic-release/publish-action@b717f67f7e7e9f709357bce5a542846503ce46ec # v10.2.0 + uses: python-semantic-release/publish-action@948bb8fccc5e8072f2c52464b45c76a8bb3878e6 # v10.5.2 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} tag: ${{ steps.release.outputs.tag }} - - name: Release | Update Minor Release Tag Reference - if: steps.release.outputs.released == 'true' && steps.release.outputs.is_prerelease == 'false' - env: - FULL_VERSION_TAG: ${{ steps.release.outputs.tag }} - GIT_COMMITTER_NAME: ${{ env.GITHUB_ACTIONS_AUTHOR_NAME }} - GIT_COMMITTER_EMAIL: ${{ env.GITHUB_ACTIONS_AUTHOR_EMAIL }} - run: | - MINOR_VERSION_TAG="$(echo "$FULL_VERSION_TAG" | cut -d. -f1,2)" - git tag --force --annotate "$MINOR_VERSION_TAG" "${FULL_VERSION_TAG}^{}" -m "$MINOR_VERSION_TAG" - git push -u origin "$MINOR_VERSION_TAG" --force - - - name: Release | Update Major Release Tag Reference - if: steps.release.outputs.released == 'true' && steps.release.outputs.is_prerelease == 'false' - env: - FULL_VERSION_TAG: ${{ steps.release.outputs.tag }} - GIT_COMMITTER_NAME: ${{ env.GITHUB_ACTIONS_AUTHOR_NAME }} - GIT_COMMITTER_EMAIL: ${{ env.GITHUB_ACTIONS_AUTHOR_EMAIL }} - run: | - MAJOR_VERSION_TAG="$(echo "$FULL_VERSION_TAG" | cut -d. -f1)" - git tag --force --annotate "$MAJOR_VERSION_TAG" "${FULL_VERSION_TAG}^{}" -m "$MAJOR_VERSION_TAG" - git push -u origin "$MAJOR_VERSION_TAG" --force - outputs: released: ${{ steps.release.outputs.released || 'false' }} new-release-version: ${{ steps.release.outputs.version }} @@ -207,7 +179,7 @@ jobs: steps: - name: Setup | Download Build Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 id: artifact-download with: name: ${{ needs.validate.outputs.distribution-artifacts }} @@ -216,7 +188,7 @@ jobs: # see https://docs.pypi.org/trusted-publishers/ - name: Publish package distributions to PyPI id: pypi-publish - uses: pypa/gh-action-pypi-publish@v1.13.0 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: packages-dir: dist print-hash: true diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index e1f292d66..67d256fea 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -14,6 +14,11 @@ on: type: boolean required: true default: true + python3-14: + description: 'Test Python 3.14?' + type: boolean + required: true + default: true python3-13: description: 'Test Python 3.13?' type: boolean @@ -60,7 +65,7 @@ jobs: steps: - name: Setup | Install Python ${{ env.COMMON_PYTHON_VERSION }} - uses: actions/setup-python@v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.COMMON_PYTHON_VERSION }} @@ -79,6 +84,7 @@ jobs: "3.11" if str(os.getenv("INPUT_PY3_11", False)).lower() == str(True).lower() else None, "3.12" if str(os.getenv("INPUT_PY3_12", False)).lower() == str(True).lower() else None, "3.13" if str(os.getenv("INPUT_PY3_13", False)).lower() == str(True).lower() else None, + "3.14" if str(os.getenv("INPUT_PY3_14", False)).lower() == str(True).lower() else None, ])) linux_versions = ( @@ -105,6 +111,7 @@ jobs: INPUT_PY3_11: ${{ inputs.python3-11 }} INPUT_PY3_12: ${{ inputs.python3-12 }} INPUT_PY3_13: ${{ inputs.python3-13 }} + INPUT_PY3_14: ${{ inputs.python3-14 }} INPUT_LINUX: ${{ inputs.linux }} INPUT_WINDOWS: ${{ inputs.windows }} run: | diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 17678d579..ba5c00d1c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Stale Issues/PRs - uses: actions/stale@v9 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: # default: 30, GitHub Actions API Rate limit is 1000/hr operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} @@ -67,7 +67,7 @@ jobs: # that point the submitter has 14 days before a reminder/warning is given. If # no response has been received within 3 weeks, the issue is closed. There are # no exemptions besides removing the awaiting-reply label. - uses: actions/stale@v9 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: # GitHub Actions API Rate limit is 1000/hr operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} @@ -97,7 +97,7 @@ jobs: # forgotten completely, this job will post a reminder message to the maintainers # No closures will occur and there are no exemptions besides removing the confirmed # label. - uses: actions/stale@v9 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: # GitHub Actions API Rate limit is 1000/hr operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c881a0707..0e1e7654c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -86,11 +86,11 @@ jobs: build: name: Build runs-on: ubuntu-latest - if: ${{ inputs.build-files-changed == 'true' || inputs.src-files-changed == 'true' || inputs.test-files-changed == 'true' || inputs.ci-files-changed == 'true' }} + if: ${{ inputs.build-files-changed == 'true' || inputs.src-files-changed == 'true' || inputs.test-files-changed == 'true' || inputs.ci-files-changed == 'true' || inputs.gha-src-files-changed == 'true' || inputs.gha-test-files-changed == 'true' }} steps: - name: Setup | Checkout Repository at workflow sha - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 @@ -100,7 +100,7 @@ jobs: git checkout -B ${{ github.ref_name }} - name: Setup | Install Python ${{ env.COMMON_PYTHON_VERSION }} - uses: actions/setup-python@v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.COMMON_PYTHON_VERSION }} cache: 'pip' @@ -112,7 +112,7 @@ jobs: - name: Build | Build next version artifacts id: version - uses: python-semantic-release/python-semantic-release@2896129e02bb7809d2cf0c1b8e9e795ee27acbcf # v10.2.0 + uses: python-semantic-release/python-semantic-release@02f2a5c74dbb6aa2989f10fc4af12cd8e6bf025f # v10.5.2 with: github_token: "" verbosity: 1 @@ -139,7 +139,7 @@ jobs: printf '%s\n' "artifacts_name=dist" >> $GITHUB_OUTPUT - name: Upload | Distribution Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ steps.build.outputs.artifacts_name }} path: ${{ steps.build.outputs.dist_dir }} @@ -161,13 +161,13 @@ jobs: steps: - name: Setup | Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.sha }} fetch-depth: 1 - name: Setup | Install Python ${{ env.LOWEST_PYTHON_VERSION }} - uses: actions/setup-python@v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.LOWEST_PYTHON_VERSION }} cache: 'pip' @@ -195,7 +195,7 @@ jobs: --junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@3585e9575db828022551b4231f165eb59a0e74e3 # v5.6.2 + uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -217,19 +217,19 @@ jobs: steps: - name: Setup | Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.sha }} fetch-depth: 1 - name: Setup | Install Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Setup | Download Distribution Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: ${{ needs.build.outputs.distribution-artifacts }} path: ./dist @@ -265,7 +265,7 @@ jobs: --junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Cached Repos on Failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('cached-repos-{0}-{1}', matrix.os, matrix.python-version) }} @@ -275,7 +275,7 @@ jobs: retention-days: 1 - name: Report | Upload Tested Repos on Failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('tested-repos-{0}-{1}', matrix.os, matrix.python-version) }} @@ -285,7 +285,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@3585e9575db828022551b4231f165eb59a0e74e3 # v5.6.2 + uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -306,19 +306,19 @@ jobs: steps: - name: Setup | Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.sha }} fetch-depth: 1 - name: Setup | Install Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Setup | Download Distribution Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: ${{ needs.build.outputs.distribution-artifacts }} path: dist @@ -363,7 +363,7 @@ jobs: `--junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Cached Repos on Failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('cached-repos-{0}-{1}', matrix.os, matrix.python-version) }} @@ -373,7 +373,7 @@ jobs: retention-days: 1 - name: Report | Upload Tested Repos on Failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('tested-repos-{0}-{1}', matrix.os, matrix.python-version) }} @@ -383,7 +383,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@3585e9575db828022551b4231f165eb59a0e74e3 # v5.6.2 + uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -397,7 +397,6 @@ jobs: needs: - build - - unit-test env: TEST_CONTAINER_TAG: psr-action:latest @@ -405,13 +404,13 @@ jobs: steps: - name: Setup | Checkout Repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.sha }} - name: Setup | Download Distribution Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: ${{ needs.build.outputs.distribution-artifacts }} path: ${{ env.ACTION_SRC_DIR }} @@ -447,13 +446,13 @@ jobs: steps: - name: Setup | Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.sha }} fetch-depth: 1 - name: Setup | Install Python ${{ env.COMMON_PYTHON_VERSION }} - uses: actions/setup-python@v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.COMMON_PYTHON_VERSION }} cache: 'pip' diff --git a/.github/workflows/verify_upstream.sh b/.github/workflows/verify_upstream.sh deleted file mode 100644 index 3e8a38ac2..000000000 --- a/.github/workflows/verify_upstream.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -set -eu +o pipefail - -# Example output of `git status -sb`: -# ## master...origin/master [behind 1] -# M .github/workflows/verify_upstream.sh -UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)" -printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" - -set -o pipefail - -if [ -z "$UPSTREAM_BRANCH_NAME" ]; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" - exit 1 -fi - -git fetch "${UPSTREAM_BRANCH_NAME%%/*}" - -if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" - exit 1 -fi - -HEAD_SHA="$(git rev-parse HEAD)" - -if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then - printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" - printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." - exit 1 -fi - -printf '%s\n' "Verified upstream branch has not changed, continuing with release..." diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3b4e00cb5..ff44a7ec7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,223 @@ CHANGELOG ========= +.. _changelog-v10.5.3: + +v10.5.3 (2025-12-14) +==================== + +🪲 Bug Fixes +------------ + +* **cmd-version**: Resolve unauthenticated git repo issues for upstream verification, closes + `#1373`_ (`PR#1388`_, `e164f68`_) + +* **github-action**: Fix failed signing issue when ssh was missing from action environment, closes + `#1376`_ (`PR#1389`_, `18b7eda`_) + +* **parser-conventional-monorepo**: Fix parser opts validator for outside dir path matches, closes + `#1380`_ (`PR#1382`_, `a51eadd`_) + +.. _#1373: https://github.com/python-semantic-release/python-semantic-release/issues/1373 +.. _#1376: https://github.com/python-semantic-release/python-semantic-release/issues/1376 +.. _#1380: https://github.com/python-semantic-release/python-semantic-release/issues/1380 +.. _18b7eda: https://github.com/python-semantic-release/python-semantic-release/commit/18b7edadd7e7dfe42ec43110acf5e1bd8bcd7eb3 +.. _a51eadd: https://github.com/python-semantic-release/python-semantic-release/commit/a51eadd8414a7e9cbfa66837ee5a840a6331dfa1 +.. _e164f68: https://github.com/python-semantic-release/python-semantic-release/commit/e164f682bfa4ca1e7cbe77aa068202fd8094eec7 +.. _PR#1382: https://github.com/python-semantic-release/python-semantic-release/pull/1382 +.. _PR#1388: https://github.com/python-semantic-release/python-semantic-release/pull/1388 +.. _PR#1389: https://github.com/python-semantic-release/python-semantic-release/pull/1389 + + +.. _changelog-v10.5.2: + +v10.5.2 (2025-11-10) +==================== + +🪲 Bug Fixes +------------ + +* **cmd-version**: Toggle verify upstream off when no version commit is made (`PR#1370`_, + `e0b3b70`_) + +.. _e0b3b70: https://github.com/python-semantic-release/python-semantic-release/commit/e0b3b7075a4c98cd7af97e0b8470872c11e7aeb9 +.. _PR#1370: https://github.com/python-semantic-release/python-semantic-release/pull/1370 + + +.. _changelog-v10.5.1: + +v10.5.1 (2025-11-10) +==================== + +🪲 Bug Fixes +------------ + +* **cmd-version**: Fix upstream change detection to succeed without branch tracking (`PR#1369`_, + `7086257`_) + +.. _7086257: https://github.com/python-semantic-release/python-semantic-release/commit/7086257b641e241dc9a8d742bd62e3698a8b8173 +.. _PR#1369: https://github.com/python-semantic-release/python-semantic-release/pull/1369 + + +.. _changelog-v10.5.0: + +v10.5.0 (2025-11-09) +==================== + +✨ Features +----------- + +* **cmd-version**: Add automatic repository un-shallowing to version workflow (`PR#1366`_, + `90a1ffa`_) + +* **cmd-version**: Add functionality to create & update partial version tags (`PR#1115`_, + `a28f940`_) + +* **cmd-version**: Adds c-macro style version definition support to ``version_variables``, closes + `#1348`_ (`PR#1349`_, `4ce1fca`_) + +* **cmd-version**: Adds upstream check into workflow to prevent commit push collisions (`PR#1360`_, + `d77193e`_) + +🪲 Bug Fixes +------------ + +* **cmd-version**: Prevent regular expression errors on ``tag_format`` (`PR#1367`_, `e7d7aa7`_) + +📖 Documentation +---------------- + +* **commands**: Add description of automated upstream version checking upon version creation + (`PR#1360`_, `d77193e`_) + +* **configuration**: Add description for ``add_partial_tags`` setting & usage examples (`PR#1115`_, + `a28f940`_) + +* **configuration**: Fix ``tag_format`` definition (`PR#1367`_, `e7d7aa7`_) + +* **configuration**: Update ``version_variables`` examples with a c-macro style replacement + (`PR#1349`_, `4ce1fca`_) + +* **github-actions**: Adds release job outputs definition to example (`PR#1344`_, `0fb4875`_) + +* **github-actions**: Removed verify upstream status step from example workflow (`PR#1360`_, + `d77193e`_) + +* **github-actions**: Update example to remove need to specify repo checkout's fetch depth + (`PR#1366`_, `90a1ffa`_) + +* **uv-integration**: Remove verify upstream check from uv integration example (`PR#1360`_, + `d77193e`_) + +* **uv-integration**: Update example to remove need to specify repo checkout's fetch depth + (`PR#1366`_, `90a1ffa`_) + +⚙️ Build System +---------------- + +* **deps**: Bump ``tomlkit`` dependency from ~=0.11.0 to ~=0.13.0 (`PR#1355`_, `55c94ec`_) + +* **deps**: Change github-actions container image to ``python:3.14-slim-trixie`` (`PR#1346`_, + `1a23712`_) + +💡 Additional Release Information +--------------------------------- + +* **cmd-version**: If you were previously handling the unshallowing of a repository clone in your + CI/CD pipelines, you may now remove that step from your workflow. PSR will now detect a shallow + repository and unshallow it before evaluating the commit history. + +.. _#1348: https://github.com/python-semantic-release/python-semantic-release/issues/1348 +.. _0fb4875: https://github.com/python-semantic-release/python-semantic-release/commit/0fb4875fa24ed283ed2d97ff6ab1879669a787ca +.. _1a23712: https://github.com/python-semantic-release/python-semantic-release/commit/1a237125badcb597ae7a92db4e01c2ff3293bce8 +.. _4ce1fca: https://github.com/python-semantic-release/python-semantic-release/commit/4ce1fcac60ac73657a4aaaaa3cb7c4afc7eac2c1 +.. _55c94ec: https://github.com/python-semantic-release/python-semantic-release/commit/55c94ecde1aec47b88aa172d031ab33afa7f795d +.. _90a1ffa: https://github.com/python-semantic-release/python-semantic-release/commit/90a1ffa55c5a1605c59cb26a1797f9a37fdfa784 +.. _a28f940: https://github.com/python-semantic-release/python-semantic-release/commit/a28f9401c4b285aa1007b72eb051d42567f33f93 +.. _d77193e: https://github.com/python-semantic-release/python-semantic-release/commit/d77193e30807968ba6a26bd356a868db62dc1098 +.. _e7d7aa7: https://github.com/python-semantic-release/python-semantic-release/commit/e7d7aa74a216cd2fdd78afc1e0e8b6b8044954ec +.. _PR#1115: https://github.com/python-semantic-release/python-semantic-release/pull/1115 +.. _PR#1344: https://github.com/python-semantic-release/python-semantic-release/pull/1344 +.. _PR#1346: https://github.com/python-semantic-release/python-semantic-release/pull/1346 +.. _PR#1349: https://github.com/python-semantic-release/python-semantic-release/pull/1349 +.. _PR#1355: https://github.com/python-semantic-release/python-semantic-release/pull/1355 +.. _PR#1360: https://github.com/python-semantic-release/python-semantic-release/pull/1360 +.. _PR#1366: https://github.com/python-semantic-release/python-semantic-release/pull/1366 +.. _PR#1367: https://github.com/python-semantic-release/python-semantic-release/pull/1367 + + +.. _changelog-v10.4.1: + +v10.4.1 (2025-09-13) +==================== + +🪲 Bug Fixes +------------ + +* **cmd-version**: Fix error where ``--no-tag`` is not respected, closes `#1304`_ (`PR#1329`_, + `b090fa2`_) + +📖 Documentation +---------------- + +* **CHANGELOG**: Update hyperlink in v10.4.0's additional info paragraph (`PR#1323`_, `98ef722`_) + +* **getting-started-guide**: Remove notice about lack of monorepo support, closes `#1326`_ + (`PR#1327`_, `3f21f3f`_) + +* **github-actions**: Fix recommended upstream detection script's upstream name parsing (`PR#1328`_, + `ccc91c0`_) + +.. _#1304: https://github.com/python-semantic-release/python-semantic-release/issues/1304 +.. _#1326: https://github.com/python-semantic-release/python-semantic-release/issues/1326 +.. _3f21f3f: https://github.com/python-semantic-release/python-semantic-release/commit/3f21f3fc47a0dacc11ec95feb2a23f8cf132e77b +.. _98ef722: https://github.com/python-semantic-release/python-semantic-release/commit/98ef722b65bd6a37492cf7ec8b0425800f719114 +.. _b090fa2: https://github.com/python-semantic-release/python-semantic-release/commit/b090fa2efc0ebfb40bdc572fea307d356af95a3f +.. _ccc91c0: https://github.com/python-semantic-release/python-semantic-release/commit/ccc91c09fab45358c7e52b42e6c0607c68c9d8f3 +.. _PR#1323: https://github.com/python-semantic-release/python-semantic-release/pull/1323 +.. _PR#1327: https://github.com/python-semantic-release/python-semantic-release/pull/1327 +.. _PR#1328: https://github.com/python-semantic-release/python-semantic-release/pull/1328 +.. _PR#1329: https://github.com/python-semantic-release/python-semantic-release/pull/1329 + + +.. _changelog-v10.4.0: + +v10.4.0 (2025-09-08) +==================== + +✨ Features +----------- + +* **config**: Add ``conventional-monorepo`` as valid ``commit_parser`` type (`PR#1143`_, `e18f866`_) + +* **parser**: Add new conventional-commits standard parser for monorepos, closes `#614`_ + (`PR#1143`_, `e18f866`_) + +📖 Documentation +---------------- + +* Add configuration guide for monorepo use with PSR (`PR#1143`_, `e18f866`_) + +* **commit-parsers**: Introduce conventional commit monorepo parser options & features (`PR#1143`_, + `e18f866`_) + +* **configuration**: Update ``commit_parser`` option with new ``conventional-monorepo`` value + (`PR#1143`_, `e18f866`_) + +💡 Additional Release Information +--------------------------------- + +* **config**: This release introduces a new built-in parser type that can be utilized for monorepo + projects. The type value is ``conventional-monorepo`` and when specified it will apply the + conventional commit parser to a monorepo environment. This parser has specialized options to help + handle monorepo projects as well. For more information, please refer to the `Monorepo Docs`_. + +.. _#614: https://github.com/python-semantic-release/python-semantic-release/issues/614 +.. _e18f866: https://github.com/python-semantic-release/python-semantic-release/commit/e18f86640a78b374a327848b9e2ba868003d1a43 +.. _Monorepo Docs: /configuration/configuration-guides/monorepos.html +.. _PR#1143: https://github.com/python-semantic-release/python-semantic-release/pull/1143 + + .. _changelog-v10.3.2: v10.3.2 (2025-09-06) diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 3dca77474..cdf9be45c 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -118,6 +118,18 @@ By default (in order): #. Create a release in the remote VCS for this tag (if supported) +.. note:: + + Before pushing changes to the remote (step 6), Python Semantic Release automatically + verifies that the upstream branch has not changed since the commit that triggered + the release. This prevents push conflicts when another commit was made to the + upstream branch while the release was being prepared. If the upstream branch has + changed, the command will exit with an error, and you will need to pull the latest + changes and run the command again. + + This verification only occurs when committing changes (``--commit``). If you are + running with ``--no-commit``, the verification will not be performed. + All of these steps can be toggled on or off using the command line options described below. Some of the steps rely on others, so some options may implicitly disable others. diff --git a/docs/concepts/commit_parsing.rst b/docs/concepts/commit_parsing.rst index 163927c39..296169c52 100644 --- a/docs/concepts/commit_parsing.rst +++ b/docs/concepts/commit_parsing.rst @@ -49,6 +49,7 @@ Built-in Commit Parsers The following parsers are built in to Python Semantic Release: - :ref:`ConventionalCommitParser ` +- :ref:`ConventionalCommitMonorepoParser ` *(available in v10.4.0+)* - :ref:`AngularCommitParser ` *(deprecated in v9.19.0)* - :ref:`EmojiCommitParser ` - :ref:`ScipyCommitParser ` @@ -65,7 +66,7 @@ Conventional Commits Parser A parser that is designed to parse commits formatted according to the `Conventional Commits Specification`_. The parser is implemented with the following -logic in relation to how PSR's core features: +logic in relation to PSR's core features: - **Version Bump Determination**: This parser extracts the commit type from the subject line of the commit (the first line of a commit message). This type is matched against @@ -127,13 +128,98 @@ logic in relation to how PSR's core features: If no commit parser options are provided via the configuration, the parser will use PSR's built-in -:py:class:`defaults `. +:py:class:`defaults `. .. _#402: https://github.com/python-semantic-release/python-semantic-release/issues/402 .. _Conventional Commits Specification: https://www.conventionalcommits.org/en/v1.0.0 ---- +.. _commit_parser-builtin-conventional-monorepo: + +Conventional Commits Monorepo Parser +"""""""""""""""""""""""""""""""""""" + +*Introduced in v10.4.0* + +.. important:: + In order for this parser to be effective, please review the section titled + :ref:`monorepos` for details on file structure, configurations, and release actions. + +This parser is an extension of the :ref:`commit_parser-builtin-conventional`, designed specifically +for monorepo environments. A monorepo environment is defined as a single source control repository +that contains multiple packages, each of which can be released independently and may have different +version numbers. + +This parser introduces two new configuration options that determine which packages are affected +by a commit. These options control whether a commit is considered for version determination, +changelog generation, and other release actions for the relevant packages. The 2 new +configuration options are +:py:class:`path_filters ` +and +:py:class:`scope_prefix `. + +**Features**: + +- **Package Specific Commit Filtering**: For monorepo support, this parser uses 2 filtering rules + to determine if a commit should be considered for a specific package. The first rule is based on + file paths that are changed in the commit and the second rule is based on the optional scope + prefix defined in the commit message. If either rule matches, then the commit is considered + relevant to that package and will be used in version determination, changelog generation, etc, + for that package. If neither rule matches, then the commit is ignored for that package. File + path filtering rules are applied first and are the primary way to determine package relevance. The + :py:class:`path_filters ` + option allows for specifying a list of file path patterns and will also support negated patterns + to ignore specific paths that otherwise would be selected from the file glob pattern. Negated + patterns are defined by prefixing the pattern with an exclamation point (``!``). File path + filtering is quite effective by itself but to handle the edge cases, the parser has the + :py:class:`scope_prefix ` + configuration option to allow the developer to specifically define when the commit is relevant + to the package. In monorepo setups, there are often shared files between packages (generally at + the root project level) that are modified occasionally but not always relevant to the package + being released. Since you do not want to define this path in the package configuration as it may + not be relevant to the release, then this parser will look for a match with the scope prefix. + The scope prefix is a regular expression that is used to match the text inside the scope field + of a Conventional Commit. The scope prefix is optional and is used only if file path filtering + does not match. Commits that have matching files in the commit will be considered relevant to + the package **regardless** if a scope prefix exists or if it matches. + +- **Version Bump Determination**: Once package-specific commit filtering is applied, the relevant + commits are passed to the Conventional Commits Parser for evaluation and then used for version + bump determination. See :ref:`commit_parser-builtin-conventional` for details. + +- **Changelog Generation**: Once package-specific commit filtering is applied, the relevant + commits are passed to the Conventional Commits Parser for evaluation and then used for + changelog generation. See :ref:`commit_parser-builtin-conventional` for details. + +- **Pull/Merge Request Identifier Detection**: Once package-specific commit filtering is applied, + the relevant commits are passed to the Conventional Commits Parser for pull/merge request + identifier detection. See :ref:`commit_parser-builtin-linked_merge_request_detection` for details. + +- **Linked Issue Identifier Detection**: Once package-specific commit filtering is applied, the + relevant commits are passed to the Conventional Commits Parser for linked issue identifier + detection. See :ref:`commit_parser-builtin-issue_number_detection` for details. + +- **Squash Commit Evaluation**: Squashed commits are separated out into individual commits with + the same set of changed files **BEFORE** the package-specific commit filtering is applied. + Each pseudo-commit is then subjected to the same filtering rules as regular commits. See + :ref:`commit_parser-builtin-squash_commit_evaluation` for details. + +- **Release Notice Footer Detection**: Once package-specific commit filtering is applied, the + relevant commits are passed to the Conventional Commits Parser for release notice footer + detection. See :ref:`commit_parser-builtin-release_notice_footer_detection` for details. + +**Limitations**: + +- ``revert`` commit type is NOT supported, see :ref:`commit_parser-builtin-conventional`'s + limitations for details. + +If no commit parser options are provided via the configuration, the parser will use PSR's +built-in +:py:class:`defaults `. + +---- + .. _commit_parser-builtin-angular: Angular Commit Parser diff --git a/docs/concepts/getting_started.rst b/docs/concepts/getting_started.rst index d34062457..81e5842ad 100644 --- a/docs/concepts/getting_started.rst +++ b/docs/concepts/getting_started.rst @@ -175,13 +175,6 @@ behavior, you will need to specify the parser options you want to override. parse_squash_commits = true ignore_merge_commits = true -.. important:: - Python Semantic Release does not currently support Monorepo projects. You will need to provide - a custom commit parser that is built for Monorepos. Follow the Monorepo-support progress in - `Issue #168 `_, - `Issue #614 `_, - and `PR #1143 `_. - Choosing your Changelog ''''''''''''''''''''''' diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index c7d4b0bfb..683cf1bfc 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -875,72 +875,32 @@ to the GitHub Release Assets as well. contents: write steps: - # Note: We checkout the repository at the branch that triggered the workflow - # with the entire history to ensure to match PSR's release branch detection - # and history evaluation. - # However, we forcefully reset the branch to the workflow sha because it is - # possible that the branch was updated while the workflow was running. This - # prevents accidentally releasing un-evaluated changes. + # Note: We checkout the repository at the branch that triggered the workflow. + # Python Semantic Release will automatically convert shallow clones to full clones + # if needed to ensure proper history evaluation. However, we forcefully reset the + # branch to the workflow sha because it is possible that the branch was updated + # while the workflow was running, which prevents accidentally releasing un-evaluated + # changes. - name: Setup | Checkout Repository on Release Branch uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} - fetch-depth: 0 - name: Setup | Force release branch to be at workflow sha run: | git reset --hard ${{ github.sha }} - - name: Evaluate | Verify upstream has NOT changed - # Last chance to abort before causing an error as another PR/push was applied to - # the upstream branch while this workflow was running. This is important - # because we are committing a version change (--commit). You may omit this step - # if you have 'commit: false' in your configuration. - # - # You may consider moving this to a repo script and call it from this step instead - # of writing it in-line. - shell: bash - run: | - set +o pipefail - - UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)" - printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" - - set -o pipefail - - if [ -z "$UPSTREAM_BRANCH_NAME" ]; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" - exit 1 - fi - - git fetch "${UPSTREAM_BRANCH_NAME%%/*}" - - if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" - exit 1 - fi - - HEAD_SHA="$(git rev-parse HEAD)" - - if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then - printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" - printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." - exit 1 - fi - - printf '%s\n' "Verified upstream branch has not changed, continuing with release..." - - name: Action | Semantic Version Release id: release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v10.3.2 + uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} git_committer_name: "github-actions" git_committer_email: "actions@users.noreply.github.com" - name: Publish | Upload to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.3.2 + uses: python-semantic-release/publish-action@v10.5.3 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -953,6 +913,9 @@ to the GitHub Release Assets as well. path: dist if-no-files-found: error + outputs: + released: ${{ steps.release.outputs.released || 'false' }} + deploy: # 1. Separate out the deploy step from the publish step to run each step at # the least amount of token privilege @@ -995,16 +958,6 @@ to the GitHub Release Assets as well. one release job in the case if there are multiple pushes to ``main`` in a short period of time. - Secondly the *Evaluate | Verify upstream has NOT changed* step is used to ensure that the - upstream branch has not changed while the workflow was running. This is important because - we are committing a version change (``commit: true``) and there might be a push collision - that would cause undesired behavior. Review Issue `#1201`_ for more detailed information. - -.. warning:: - You must set ``fetch-depth`` to 0 when using ``actions/checkout@v4``, since - Python Semantic Release needs access to the full history to build a changelog - and at least the latest tags to determine the next version. - .. warning:: The ``GITHUB_TOKEN`` secret is automatically configured by GitHub, with the same permissions role as the user who triggered the workflow run. This causes @@ -1015,6 +968,19 @@ to the GitHub Release Assets as well. case, you will also need to pass the new token to ``actions/checkout`` (as the ``token`` input) in order to gain push access. +.. note:: + As of v10.5.0, Python Semantic Release automatically detects and converts + shallow clones to full clones when needed. While you can still use ``fetch-depth: 0`` + with ``actions/checkout@v4`` to fetch the full history upfront, it is no longer + required. If you use the default shallow clone, Python Semantic Release will + automatically fetch the full history before evaluating commits. If you are using + an older version of PSR, you will need to unshallow the repository prior to use. + +.. note:: + As of v10.5.0, the verify upstream step is no longer required as it has been + integrated into PSR directly. If you are using an older version of PSR, you will need + to review the older documentation for that step. See Issue `#1201`_ for more details. + .. _#1201: https://github.com/python-semantic-release/python-semantic-release/issues/1201 .. _concurrency: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency @@ -1039,7 +1005,7 @@ The equivalent GitHub Action configuration would be: - name: Action | Semantic Version Release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v10.3.2 + uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} force: patch @@ -1098,14 +1064,14 @@ Publish Action. - name: Release submodule 1 id: release-submod-1 - uses: python-semantic-release/python-semantic-release@v10.3.2 + uses: python-semantic-release/python-semantic-release@v10.5.3 with: directory: ${{ env.SUBMODULE_1_DIR }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Release submodule 2 id: release-submod-2 - uses: python-semantic-release/python-semantic-release@v10.3.2 + uses: python-semantic-release/python-semantic-release@v10.5.3 with: directory: ${{ env.SUBMODULE_2_DIR }} github_token: ${{ secrets.GITHUB_TOKEN }} @@ -1117,7 +1083,7 @@ Publish Action. # ------------------------------------------------------------------- # - name: Publish | Upload package 1 to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.3.2 + uses: python-semantic-release/publish-action@v10.5.3 if: steps.release-submod-1.outputs.released == 'true' with: directory: ${{ env.SUBMODULE_1_DIR }} @@ -1125,7 +1091,7 @@ Publish Action. tag: ${{ steps.release-submod-1.outputs.tag }} - name: Publish | Upload package 2 to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.3.2 + uses: python-semantic-release/publish-action@v10.5.3 if: steps.release-submod-2.outputs.released == 'true' with: directory: ${{ env.SUBMODULE_2_DIR }} diff --git a/docs/configuration/configuration-guides/index.rst b/docs/configuration/configuration-guides/index.rst index 70024dd11..d094496dd 100644 --- a/docs/configuration/configuration-guides/index.rst +++ b/docs/configuration/configuration-guides/index.rst @@ -11,4 +11,5 @@ more specific configurations. .. toctree:: :maxdepth: 1 + Monorepos UV Project Setup diff --git a/docs/configuration/configuration-guides/monorepos-ex-easy-before-release.png b/docs/configuration/configuration-guides/monorepos-ex-easy-before-release.png new file mode 100644 index 000000000..6c9bc6113 Binary files /dev/null and b/docs/configuration/configuration-guides/monorepos-ex-easy-before-release.png differ diff --git a/docs/configuration/configuration-guides/monorepos-ex-easy-post-release.png b/docs/configuration/configuration-guides/monorepos-ex-easy-post-release.png new file mode 100644 index 000000000..76693ecd3 Binary files /dev/null and b/docs/configuration/configuration-guides/monorepos-ex-easy-post-release.png differ diff --git a/docs/configuration/configuration-guides/monorepos.rst b/docs/configuration/configuration-guides/monorepos.rst new file mode 100644 index 000000000..1f173998c --- /dev/null +++ b/docs/configuration/configuration-guides/monorepos.rst @@ -0,0 +1,358 @@ +.. _monorepos: + +Releasing Packages from a Monorepo +================================== + + +A monorepo (mono-repository) is a software development strategy where code for multiple projects is stored in a single source control system. This approach streamlines and consolidates configuration, but introduces complexities when using automated tools like Python Semantic Release (PSR). + +Previously, PSR offered limited compatibility with monorepos. As of v10.4.0, PSR introduces the :ref:`commit_parser-builtin-conventional-monorepo`, designed specifically for monorepo environments. To fully leverage this new parser, you must configure your monorepo as described below. + +.. _monorepos-config: + +Configuring PSR +--------------- + +.. _monorepos-config-example_simple: + +Example: Simple +""""""""""""""" + + +**Directory Structure**: PSR does not yet support a single, workspace-level configuration definition. This means each package in the monorepo requires its own PSR configuration file. A compatible and common monorepo file structure looks like: + +.. code:: + + project/ + ├── .git/ + ├── .venv/ + ├── packages/ + │ ├── pkg1/ + │ │ ├── docs/ + │ │ │ └── source/ + │ │ │ ├── conf.py + │ │ │ └── index.rst + │ │ │ + │ │ ├── src/ + │ │ │ └── pkg1/ + │ │ │ ├── __init__.py + │ │ │ ├── __main__.py + │ │ │ └── py.typed + │ │ │ + │ │ ├── CHANGELOG.md + │ │ ├── README.md + │ │ └── pyproject.toml <-- PSR Configuration for Package 1 + │ │ + │ └── pkg2/ + │ ├── docs/ + │ │ └── source/ + │ │ ├── conf.py + │ │ └── index.rst + │ ├── src/ + │ │ └── pkg2/ + │ │ ├── __init__.py + │ │ ├── __main__.py + │ │ └── py.typed + │ │ + │ ├── CHANGELOG.md + │ ├── README.md + │ └── pyproject.toml <-- PSR Configuration for Package 2 + │ + ├── .gitignore + └── README.md + + +This is the most basic monorepo structure, where each package is self-contained with its own configuration files, documentation, and CHANGELOG files. To release a package, change your current working directory to the package directory and execute PSR's :ref:`cmd-version`. PSR will automatically read the package's ``pyproject.toml``, looking for the ``[tool.semantic_release]`` section to determine the package's versioning and release configuration, then search up the file tree to find the Git repository. + +Because there is no workspace-level configuration, you must duplicate any common PSR configuration in each package's configuration file. Customize each configuration for each package to specify how PSR should distinguish between commits. + +With the example file structure above, here is an example configuration file for each package: + +.. code-block:: toml + + # FILE: pkg1/pyproject.toml + [project] + name = "pkg1" + version = "1.0.0" + + [tool.semantic_release] + commit_parser = "conventional-monorepo" + commit_message = """\ + chore(release): pkg1@{version}` + + Automatically generated by python-semantic-release + """ + tag_format = "pkg1-v{version}" + version_toml = ["pyproject.toml:project.version"] + + [tool.semantic_release.commit_parser_options] + path_filters = ["."] + scope_prefix = "pkg1-" + +.. code-block:: toml + + # FILE: pkg2/pyproject.toml + [project] + name = "pkg2" + version = "1.0.0" + + [tool.semantic_release] + commit_parser = "conventional-monorepo" + commit_message = """\ + chore(release): pkg2@{version}` + + Automatically generated by python-semantic-release + """ + tag_format = "pkg2-v{version}" + version_toml = ["pyproject.toml:project.version"] + + [tool.semantic_release.commit_parser_options] + path_filters = ["."] + scope_prefix = "pkg2-" + + +These are the minimum configuration options required for each package. Note the use of :ref:`config-tag_format` to distinguish tags between packages. The commit parser options are specific to the new :ref:`commit_parser-builtin-conventional-monorepo` and play a significant role in identifying which commits are relevant to each package. Since you are expected to change directories to each package before releasing, file paths in each configuration file should be relative to the package directory. + +Each package also defines a slightly different :ref:`config-commit_message` to reflect the package name in each message. This helps clarify which release number is being updated in the commit history. + + +Release Steps +''''''''''''' + +Given the following Git history of a monorepo using a GitHub Flow branching strategy (without CI/CD): + +.. image:: ./monorepos-ex-easy-before-release.png + +To manually release both packages, run: + +.. code-block:: bash + + cd packages/pkg1 + semantic-release version + # 1.0.1 (tag: pkg1-v1.0.1) + + cd ../pkg2 + semantic-release version + # 1.1.0 (tag: pkg2-v1.1.0) + +After releasing both packages, the resulting Git history will look like: + +.. image:: ./monorepos-ex-easy-post-release.png + +.. seealso:: + + - :ref:`GitHub Actions with Monorepos ` + + +Considerations +'''''''''''''' + +1. **Custom Changelogs**: Managing changelogs can be tricky depending on where you want to write the changelog files. In this simple example, the changelog is located within each package directory, and the changelog template does not have any package-specific formatting or naming convention. You can use one shared template directory at the root of the project and configure each package to point to the shared template directory. + +.. code-block:: toml + + # FILE: pkg1/pyproject.toml + [tool.semantic_release] + template_dir = "../../config/release-templates" + +.. code-block:: toml + + # FILE: pkg2/pyproject.toml + [tool.semantic_release] + template_dir = "../../config/release-templates" + +.. code:: + + project/ + ├── .git/ + ├── config/ + │ └── release-templates/ + │ ├── CHANGELOG.md.j2 + │ └── .release_notes.md.j2 + ├── packages/ + │ ├── pkg1/ + │ │ ├── CHANGELOG.md + │ │ └── pyproject.toml + │ │ + │ └── pkg2/ + │ ├── CHANGELOG.md + │ └── pyproject.toml + │ + ├── .gitignore + └── README.md + +.. seealso:: + + - For situations with more complex documentation needs, see our :ref:`Advanced Example `. + + +2. **Package Prereleases**: Creating pre-releases is possible, but it is recommended to use package-prefixed branch names to avoid collisions between packages. For example, to enable alpha pre-releases for new features in both packages, use the following configuration: + +.. code-block:: toml + + # FILE: pkg1/pyproject.toml + [tool.semantic_release.branches.alpha-release] + match = "^pkg1/feat/.+" # <-- note pkg1 prefix + prerelease = true + prerelease_token = "alpha" + +.. code-block:: toml + + # FILE: pkg2/pyproject.toml + [tool.semantic_release.branches.alpha-release] + match = "^pkg2/feat/.+" # <-- note pkg2 prefix + prerelease = true + prerelease_token = "alpha" + +---- + +.. _monorepos-config-example_advanced: + +Example: Advanced +""""""""""""""""" + + +If you want to consolidate documentation into a single top-level directory, the setup becomes more complex. In this example, there is a common documentation folder at the top level, and each package has its own subfolder within the documentation folder. + +Due to naming conventions, PSR cannot automatically accomplish this with its default changelog templates. For this scenario, you must copy the internal PSR templates into a custom directory (even if you do not modify them) and add custom scripting to prepare for each release. + +The directory structure looks like: + +.. code:: + + project/ + ├── .git/ + ├── docs/ + │ ├── source/ + │ │ ├── pkg1/ + │ │ │ ├── changelog.md + │ │ │ └── README.md + │ │ ├── pkg2/ + │ │ │ ├── changelog.md + │ │ │ └── README.md + │ │ └── index.rst + │ │ + │ └── templates/ + │ ├── .base_changelog_template/ + │ │ ├── components/ + │ │ │ ├── changelog_header.md.j2 + │ │ │ ├── changelog_init.md.j2 + │ │ │ ├── changelog_update.md.j2 + │ │ │ ├── changes.md.j2 + │ │ │ ├── first_release.md.j2 + │ │ │ ├── macros.md.j2 + │ │ │ ├── unreleased_changes.md.j2 + │ │ │ └── versioned_changes.md.j2 + │ │ └── changelog.md.j2 + │ ├── .gitignore + │ └── .release_notes.md.j2 + │ + ├── packages/ + │ ├── pkg1/ + │ │ ├── src/ + │ │ │ └── pkg1/ + │ │ │ ├── __init__.py + │ │ │ └── __main__.py + │ │ └── pyproject.toml + │ │ + │ └── pkg2/ + │ ├── src/ + │ │ └── pkg2/ + │ │ ├── __init__.py + │ │ └── __main__.py + │ └── pyproject.toml + │ + └── scripts/ + ├── release-pkg1.sh + └── release-pkg2.sh + + +Each package should point to the ``docs/templates/`` directory to use a common release notes template. PSR ignores hidden files and directories when searching for template files to create, allowing you to hide shared templates in the directory for use in your release setup script. + +Here is our configuration file for package 1 (package 2 is similarly defined): + +.. code-block:: toml + + # FILE: pkg1/pyproject.toml + [project] + name = "pkg1" + version = "1.0.0" + + [tool.semantic_release] + commit_parser = "conventional-monorepo" + commit_message = """\ + chore(release): Release `pkg1@{version}` + + Automatically generated by python-semantic-release + """ + tag_format = "pkg1-v{version}" + version_toml = ["pyproject.toml:project.version"] + + [tool.semantic_release.commit_parser_options] + path_filters = [ + ".", + "../../../docs/source/pkg1/**", + ] + scope_prefix = "pkg1-" + + [tool.semantic_release.changelog] + template_dir = "../../../docs/templates" + mode = "update" + exclude_commit_patterns = [ + '''^chore(?:\([^)]*?\))?: .+''', + '''^ci(?:\([^)]*?\))?: .+''', + '''^refactor(?:\([^)]*?\))?: .+''', + '''^style(?:\([^)]*?\))?: .+''', + '''^test(?:\([^)]*?\))?: .+''', + '''^Initial [Cc]ommit''', + ] + + [tool.semantic_release.changelog.default_templates] + # To enable update mode: this value must set here because the default is not the + # same as the default in the other package & must be the final destination filename + # for the changelog relative to this file + changelog_file = "../../../docs/source/pkg1/changelog.md" + + +Note: In this configuration, we added path filters for additional documentation files related to the package so that the changelog will include documentation changes as well. + + +Next, define a release script to set up the common changelog templates in the correct directory format so PSR will create the desired files at the proper locations. Following the :ref:`changelog-templates-template-rendering` reference, you must define the folder structure from the root of the project within the templates directory so PSR will properly lay down the files across the repository. The script cleans up any previous templates, dynamically creates the necessary directories, and copies over the shared templates into a package-named directory. Now you are prepared to run PSR for a release of ``pkg1``. + +.. code-block:: bash + + #!/bin/bash + # FILE: scripts/release-pkg1.sh + + set -euo pipefail + + PROJECT_ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")" + VIRTUAL_ENV="$PROJECT_ROOT/.venv" + + PACKAGE_NAME="pkg1" + + cd "$PROJECT_ROOT" || exit 1 + + # Setup documentation template + pushd "docs/templates" >/dev/null || exit 1 + + rm -rf docs/ + mkdir -p "docs/source/" + cp -r .base_changelog_template/ "docs/source/$PACKAGE_NAME" + + popd >/dev/null || exit 1 + + # Release the package + pushd "packages/$PACKAGE_NAME" >/dev/null || exit 1 + + printf '%s\n' "Releasing $PACKAGE_NAME..." + "$VIRTUAL_ENV/bin/semantic-release" -v version --no-push + + popd >/dev/null || exit 1 + + +That's it! This example demonstrates how to set up a monorepo with shared changelog templates and a consolidated documentation folder for multiple packages. + +.. seealso:: + + - Advanced Example Monorepo: `codejedi365/psr-monorepo-poweralpha `_ diff --git a/docs/configuration/configuration-guides/uv_integration.rst b/docs/configuration/configuration-guides/uv_integration.rst index 79039477e..ac9f2359e 100644 --- a/docs/configuration/configuration-guides/uv_integration.rst +++ b/docs/configuration/configuration-guides/uv_integration.rst @@ -161,7 +161,6 @@ look like this: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.sha }} - fetch-depth: 0 - name: Setup | Force correct release branch on workflow sha run: git checkout -B ${{ github.ref_name }} @@ -259,7 +258,6 @@ look like this: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.ref_name }} - fetch-depth: 0 - name: Setup | Force release branch to be at workflow sha run: git reset --hard ${{ github.sha }} @@ -291,7 +289,6 @@ look like this: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - bash .github/workflows/verify_upstream.sh uv run semantic-release -v --strict version --skip-build uv run semantic-release publish diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index d9c154a93..690a3e4c3 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -796,6 +796,7 @@ within the Git repository. Built-in parsers: * ``angular`` - :ref:`AngularCommitParser ` *(deprecated in v9.19.0)* * ``conventional`` - :ref:`ConventionalCommitParser ` *(available in v9.19.0+)* + * ``conventional-monorepo`` - :ref:`ConventionalCommitMonorepoParser ` *(available in v10.4.0+)* * ``emoji`` - :ref:`EmojiCommitParser ` * ``scipy`` - :ref:`ScipyCommitParser ` * ``tag`` - :ref:`TagCommitParser ` *(deprecated in v9.12.0)* @@ -1161,6 +1162,52 @@ from the :ref:`remote.name ` location of your git repository ---- +.. _config-add_partial_tags: + +``add_partial_tags`` +"""""""""""""""""""" + +**Type:** ``bool`` + +Specify if partial version tags should be handled when creating a new version. If set to +``true``, a ``major`` and a ``major.minor`` tag will be created or updated, using the format +specified in :ref:`tag_format`. If version has build metadata, a ``major.minor.patch`` tag +will also be created or updated. + +Partial version tags are **disabled** for pre-release versions. + +**Example** + +.. code-block:: toml + + [semantic_release] + tag_format = "v{version}" + add_partial_tags = true + +This configuration with the next version of ``1.2.3`` will result in: + +.. code-block:: bash + + git log --decorate --oneline --graph --all + # * 4d4cb0a (tag: v1.2.3, tag: v1.2, tag: v1, origin/main, main) 1.2.3 + # * 3a2b1c0 fix: some bug + # * 2b1c0a9 (tag: v1.2.2) 1.2.2 + # ... + +If build-metadata is used, the next version of ``1.2.3+20251109`` will result in: + +.. code-block:: bash + + git log --decorate --oneline --graph --all + # * 4d4cb0a (tag: v1.2.3+20251109, tag: v1.2.3, tag: v1.2, tag: v1, origin/main, main) 1.2.3+20251109 + # * 3a2b1c0 chore: add partial tags to PSR configuration + # * 2b1c0a9 (tag: v1.2.3+20251031) 1.2.3+20251031 + # ... + +**Default:** ``false`` + +---- + .. _config-tag_format: ``tag_format`` @@ -1169,17 +1216,8 @@ from the :ref:`remote.name ` location of your git repository **Type:** ``str`` Specify the format to be used for the Git tag that will be added to the repo during -a release invoked via :ref:`cmd-version`. The format string is a regular expression, -which also must include the format keys below, otherwise an exception will be thrown. -It *may* include any of the optional format keys, in which case the contents -described will be formatted into the specified location in the Git tag that is created. - -For example, ``"(dev|stg|prod)-v{version}"`` is a valid ``tag_format`` matching tags such -as: - -- ``dev-v1.2.3`` -- ``stg-v0.1.0-rc.1`` -- ``prod-v2.0.0+20230701`` +a release invoked via :ref:`cmd-version`. The string is used as a template for the tag +name, and must include the ``{version}`` format key. This format will also be used for parsing tags already present in the repository into semantic versions; therefore if the tag format changes at some point in the @@ -1195,6 +1233,13 @@ Format Key Mandatory Contents Tags which do not match this format will not be considered as versions of your project. +This is critical for Monorepo projects where the tag format defines which package the +version tag belongs to. Generally, the tag format for each package of the monorepo will +include the package name as the prefix of the tag format. For example, if the package +is named ``pkg1``, the tag format would be ``pkg1-v{version}`` and in the other package +``pkg2``, the tag format would be ``pkg2-v{version}``. This allows PSR to determine +which tags to use to determine the version for each package. + **Default:** ``"v{version}"`` ---- @@ -1353,7 +1398,8 @@ The regular expression generated from the ``version_variables`` definition will: 2. The variable name defined by ``variable`` and the version must be separated by an operand symbol (``=``, ``:``, ``:=``, or ``@``). Whitespace is optional around the symbol. As of v10.0.0, a double-equals (``==``) operator is also supported - as a valid operand symbol. + as a valid operand symbol. As of v10.5.0, PSR can omit all operands as long + as there is at least one whitespace character between the variable name and the version. 3. The value of the variable must match a `SemVer`_ regular expression and can be enclosed by single (``'``) or double (``"``) quotation marks but they must match. However, @@ -1409,6 +1455,9 @@ will be matched and replaced by the new version: # requirements.txt my-package == 1.2.3 + # C-Macro style (no operand only whitespace required) + #define VERSION "1.2.3" + .. important:: The Regular Expression expects a version value to exist in the file to be replaced. It cannot be an empty string or a non-semver compliant string. If this is the very diff --git a/pyproject.toml b/pyproject.toml index ce10f324b..19b636cb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "10.3.2" +version = "10.5.3" description = "Automatic Semantic Versioning for Python projects" requires-python = "~= 3.8" license = { text = "MIT" } @@ -19,9 +19,13 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] readme = "README.rst" -authors = [{ name = "Rolf Erik Lekang", email = "me@rolflekang.com" }] +authors = [ + { name = "Rolf Erik Lekang", email = "me@rolflekang.com" }, + { name = "codejedi365", email = "codejedi365@gmail.com" }, +] dependencies = [ "click ~= 8.1.0", "click-option-group ~= 0.5", @@ -29,7 +33,7 @@ dependencies = [ "requests ~= 2.25", "jinja2 ~= 3.1", "python-gitlab >= 4.0.0, < 7.0.0", - "tomlkit ~= 0.11", + "tomlkit ~= 0.13.0", "dotty-dict ~= 1.3", "importlib-resources ~= 6.0", "pydantic ~= 2.0", @@ -52,13 +56,14 @@ repository = "http://github.com/python-semantic-release/python-semantic-release. [project.optional-dependencies] build = [ - "build ~= 1.2" + "build ~= 1.2", + "tomlkit ~= 0.13.0", ] docs = [ - "Sphinx ~= 6.0", - "sphinxcontrib-apidoc == 0.5.0", + "Sphinx ~= 7.4", + "sphinxcontrib-apidoc == 0.6.0", "sphinx-autobuild == 2024.2.4", - "furo ~= 2024.1", + "furo ~= 2025.9", ] test = [ "coverage[toml] ~= 7.0", @@ -68,9 +73,9 @@ test = [ "pyyaml ~= 6.0", "pytest ~= 8.3", "pytest-clarity ~= 1.0", - "pytest-cov >= 5.0.0, < 7.0.0", + "pytest-cov >= 5.0.0, < 8.0.0", "pytest-env ~= 1.0", - "pytest-lazy-fixtures ~= 1.1.1", + "pytest-lazy-fixtures ~= 1.4", "pytest-mock ~= 3.0", "pytest-order ~= 1.3", "pytest-pretty ~= 1.2", @@ -79,7 +84,7 @@ test = [ "requests-mock ~= 1.10", ] dev = [ - "pre-commit ~= 3.5", + "pre-commit ~= 4.3", "tox ~= 4.11", "ruff == 0.6.1" ] @@ -404,9 +409,15 @@ sections = { "tests" = ["tests"] } ignore_names = ["change_to_ex_proj_dir", "init_example_project"] [tool.semantic_release] +add_partial_tags = true logging_use_named_masks = true commit_parser = "conventional" commit_parser_options = { parse_squash_commits = true, ignore_merge_commits = true } +commit_message = """\ +chore: release v{version} + +Automatically generated by python-semantic-release +""" build_command = """ python -m pip install -e .[build] python -m build . diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 000000000..2aa90ba99 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +set -eu -o pipefail + +function load_env() { + set -eu -o pipefail + + if [ "${UTILITIES_SH_LOADED:-false}" = "false" ]; then + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + local __DIR__="" + __DIR__="$(realpath "$(dirname "$__FILE__")")" + + local ROOT_DIR="" + ROOT_DIR="$(realpath "$(dirname "$__DIR__")")" + + # shellcheck source=scripts/utils.sh + source "$ROOT_DIR/scripts/utils.sh" + + load_base_env + fi +} + +function with_temp_working_dir() { + local working_dir="${1:?"Working directory not specified, but is required!"}" + pushd "$working_dir" >/dev/null || return 1 + explicit_run_cmd "${@:2}" || return 1 + popd >/dev/null || return 1 +} + +function build_sdist() { + local status_msg="${1:?"Status message not specified, but is required!"}" + local output_dir="${2:?"Output directory not specified, but is required!"}" + + if ! explicit_run_cmd_w_status_wrapper \ + "$status_msg" \ + python3 -m build --sdist . --outdir "$output_dir" ">/dev/null"; + then + return 1 + fi + find "$output_dir" -type f -name "*.tar.gz" -exec \ + sh -c 'printf "%s\n" "Successfully built $1"' shell {} \; +} + +function unpack_sdist() { + local sdist_file="${1:?"Source distribution file not specified, but is required!"}" + local output_dir="${2:?"Output directory not specified, but is required!"}" + + mkdir -p "$output_dir" + if ! explicit_run_cmd_w_status_wrapper \ + "Unpacking sdist code into '$output_dir'" \ + tar -xzf "$sdist_file" -C "$output_dir" --strip-components=1; + then + return 1 + fi +} + + +function strip_optional_dependencies { + local -r pyproject_file="${1:?'param[1]: Path to pyproject.toml file is required'}" + local -r exclude_groups=("${@:2}") + local python_snippet="\ + from pathlib import Path + from sys import argv, exit + try: + import tomlkit + except ModuleNotFoundError: + print('Failed Import: Missing build requirement \'tomlkit\'.') + exit(1) + + pyproject_file = Path(argv[1]) + config = tomlkit.loads(pyproject_file.read_text()) + proj_config = config.get('project', {}) + + if not (opt_deps := proj_config.get('optional-dependencies', {})): + exit(0) + + if not (dep_group_to_remove := argv[2:]): + exit(0) + + for group in dep_group_to_remove: + if group in opt_deps: + opt_deps.pop(group) + + if not opt_deps: + proj_config.pop('optional-dependencies') + + pyproject_file.write_text(tomlkit.dumps(config)) + " + # make whitespace nice for python (remove indent) + python_snippet="$(printf '%s\n' "$python_snippet" | sed -E 's/([ ]{4,8}|\t)(.*)/\2/')" + + if [ "${#exclude_groups[@]}" -eq 0 ]; then + error "At least one dependency group to exclude must be specified!" + return 1 + fi + + python3 -c "$python_snippet" "$pyproject_file" "${exclude_groups[@]}" || return 1 +} + +remove_empty_init_files() { + local dirpath="${1:-.}" + + # SNIPPET: Remove empty __init__.py files + local python_snippet='\ + from pathlib import Path + from sys import exit, argv, stderr + + if len(argv) < 2 or not (dirpath := Path(argv[1])).is_dir(): + print("Usage: ", file=stderr) + exit(1) + + for filepath in dirpath.resolve().rglob("__init__.py"): + if not filepath.is_file(): + continue + if not filepath.read_text().strip(): + filepath.unlink() + print(f"Removed {filepath}") + ' + # make whitespace nice for python + python_snippet="$(printf '%s\n' "$python_snippet" | sed -E 's/([ ]{4,8}|\t)(.*)/\2/')" + + python3 -c "$python_snippet" "$dirpath" || return 1 +} + +function build_production_whl() { + # Assumes the current working directory is the directory to modify + local dest_dir="${1:?"param[1]: output directory not specified, but required!"}" + + # Strip out development dependencies + explicit_run_cmd_w_status_wrapper \ + "Masking development dependencies" \ + strip_optional_dependencies "pyproject.toml" "build" "dev" "docs" "test" "mypy" || return 1 + + # Optimize code for runtime + explicit_run_cmd_w_status_wrapper \ + "Removing empty '__init__.py' files" \ + remove_empty_init_files "src" || return 1 + + # Remove editable info from the source directory before wheel build + rm -rf src/*.egg-info/ + + # Build the wheel into the output directory + explicit_run_cmd_w_status_wrapper \ + "Constructing wheel package" \ + python3 -m build --wheel . --outdir "$dest_dir" || return 1 +} + +function build_wheel_from_sdist() { + local build_dir="${1:?"param[1]: Build directory not specified, but is required!"}" + local dest_dir="${2:?"param[2]: Output directory not specified, but is required!"}" + local tmp_src_dir="$build_dir/sdist" + + unpack_sdist "$build_dir/*.tar.gz" "$tmp_src_dir" || return 1 + + with_temp_working_dir "$tmp_src_dir" build_production_whl "$dest_dir" || return 1 + + rm -rf "$tmp_src_dir" +} + +function build_production_package() { + local dest_dir + local output_dir="${1:?"param[1]: Output directory not specified, but required!"}" + local build_dir="build" + + # If the output directory is not an absolute path, make it absolute + if ! stdout "$output_dir" | grep -q -E '^/'; then + dest_dir="$(realpath ".")/$output_dir" + else + dest_dir="$output_dir" + fi + + # Clean up any existing output directory + if [ -d "$dest_dir" ]; then + rm -rf "$dest_dir" + fi + + # Clean up any existing build directory + if [ -d "$build_dir" ]; then + rm -rf "$build_dir" + fi + + build_sdist "Bundling source code" "$build_dir" || return 1 + + explicit_run_cmd_w_status_wrapper \ + "Building production wheel from sdist" \ + build_wheel_from_sdist "$build_dir" "$dest_dir" || return 1 + + rm -rf "$build_dir" +} + +function main() { + set -eu -o pipefail + + cd "$PROJ_ROOT_DIR" + + if ! explicit_run_cmd_w_status_wrapper \ + "Verifying Python environment" \ + verify_python "$MINIMUM_PYTHON_VERSION"; + then + info "Please run the dev setup script and activate the virtual environment first." + return 1 + fi + + explicit_run_cmd_w_status_wrapper \ + "Verifying build dependencies exist" \ + python3 -m pip install -e ".[build]" ">/dev/null" + + explicit_run_cmd_w_status_wrapper \ + "Building production package" \ + build_production_package "dist" +} + +######################################################################## +# CONDITIONAL AUTO-EXECUTE # +######################################################################## + +if ! (return 0 2>/dev/null); then + # Since this script is not being sourced, run the main function + unset -v UTILITIES_SH_LOADED # Ensure utils are reloaded when called from another script + load_env + main "$@" +fi diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100644 index 000000000..384f9735b --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +function load_base_env() { + set -eu -o pipefail + + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + PROJ_ROOT_DIR="$(realpath "$(dirname "$(realpath "$(dirname "$__FILE__")")")")" + export PROJ_ROOT_DIR + + export DIST_DIR="$PROJ_ROOT_DIR/dist" + export SCRIPTS_DIR="$PROJ_ROOT_DIR/scripts" + export VENV_DIR="$PROJ_ROOT_DIR/.venv" + export PROJECT_CONFIG_FILE="$PROJ_ROOT_DIR/pyproject.toml" + export MINIMUM_PYTHON_VERSION="3.9" + export PIP_DISABLE_PIP_VERSION_CHECK="true" +} + +function stdout { printf "%b\n" "$*"; } +function stderr { stdout "$@" >&2; } +function info { stdout "[+] $*"; } + +function warning { + local prefix="[!] " + if [ "${CI:-false}" = "true" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then + prefix="::notice::" + fi + stderr "${prefix}WARNING: $*"; +} + +function error { + local prefix="[-] " + if [ "${CI:-false}" = "true" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then + prefix="::error::" + fi + stderr "${prefix}ERROR: $*"; +} + +function is_command { + local cmd="${1:?"param[1]: missing command to check."}" + command -v "$cmd" >/dev/null || { + error "Command '$cmd' not found." + return 1 + } +} + +function explicit_run_cmd { + local cmd="${1:?"param[1]: command not specified, but is required!"}" + set -- "${@:2}" # shift off the first argument + local args="$*" + + # Default as a function call + local log_msg="$cmd($args)" + + # Needs to run in bash because zsh which will return 0 for a defined function + if bash -c "which $cmd >/dev/null"; then + log_msg="${SHELL:-/bin/sh} -c '$cmd $args'" + fi + + stderr " $log_msg" + eval "$cmd $args" +} + +function explicit_run_cmd_w_status_wrapper { + local status_msg="${1:?"param[1]: status message not specified, but is required!"}" + local cmd="${2:?"param[2]: command not specified, but is required!"}" + set -- "${@:3}" # shift off the first two arguments + + if [ -z "$cmd" ]; then + error "Command not specified, but is required!" + return 1 + fi + + info "${status_msg}..." + if ! explicit_run_cmd "$cmd" "$@"; then + error "${status_msg}...FAILED" + return 1 + fi + info "${status_msg}...DONE" +} + +function verify_python_version() { + local python3_exe="${1:?"param[1]: path to python3 executable is required"}" + local min_version="${2:?"param[2]: minimum python version is required"}" + + if ! [[ "$min_version" =~ ^v?[0-9]+(\.[0-9]+){0,2}$ ]]; then + error "Invalid minimum python version format: '$min_version'. Expected format: 'X', 'X.Y', or 'X.Y.Z'" + return 1 + fi + + local min_major_version="" + min_major_version="$(stdout "$min_version" | cut -d. -f1 | tr -d 'v')" + + local min_minor_version="" + min_minor_version="$(stdout "$min_version" | cut -d. -f2)" + min_minor_version="${min_minor_version:-0}" + + local min_patch_version="" + min_patch_version="$(stdout "$min_version" | cut -d. -f3)" + min_patch_version="${min_patch_version:-0}" + + local python_version_str="" + if ! python_version_str="$("$python3_exe" --version 2>&1 | awk '{print $2}')"; then + error "Failed to get python version string from '$python3_exe'" + return 1 + fi + + local python_major_version="" + python_major_version="$(stdout "$python_version_str" | cut -d. -f1)" + + local python_minor_version="" + python_minor_version="$(stdout "$python_version_str" | cut -d. -f2)" + + local python_patch_version="" + python_patch_version="$(stdout "$python_version_str" | cut -d. -f3)" + + if [ "$python_major_version" -ne "$min_major_version" ]; then + error "Python major version mismatch! Required version: $min_major_version, Found version: $python_version_str" + return 1 + fi + + if [ "$python_minor_version" -lt "$min_minor_version" ] || [ "$python_patch_version" -lt "$min_patch_version" ]; then + error "Python version ^${min_major_version}.${min_minor_version}.${min_patch_version}+ is required! Found version: $python_version_str" + return 1 + fi +} + +function verify_python() { + set -eu -o pipefail + local -r min_python_version="${1:?"param[1]: minimum python version parameter is required!"}" + + is_command "python3" || { + error "Python 3 is not detected. Script requires Python $min_python_version+!" + return 1 + } + + local python3_exe="" + python3_exe="$(which python3)" + + if ! [ -f "$(dirname "$python3_exe")/../pyvenv.cfg" ]; then + error "No virtual environment detected." + return 1 + fi + + verify_python_version "$python3_exe" "$min_python_version" +} + +export UTILITIES_SH_LOADED="true" diff --git a/scripts/watch_docs.sh b/scripts/watch_docs.sh new file mode 100644 index 000000000..e3608442e --- /dev/null +++ b/scripts/watch_docs.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +if command -v realpath >/dev/null 2>&1; then + PROJ_ROOT=$(realpath "$(dirname "${BASH_SOURCE[0]}")/..") +elif command -v readlink >/dev/null 2>&1; then + PROJ_ROOT=$(readlink -f "$(dirname "${BASH_SOURCE[0]}")/..") +else + PROJ_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +fi + +[ -z "$VIRTUAL_ENV" ] && VIRTUAL_ENV=".venv" +SPHINX_AUTOBUILD_EXE="$VIRTUAL_ENV/bin/sphinx-autobuild" + +cd "$PROJ_ROOT" || exit 1 + +if [ ! -f "$SPHINX_AUTOBUILD_EXE" ]; then + printf '%s\n' "sphinx-autobuild is not installed in the virtual environment. Please install the docs extras." + exit 1 +fi + +rm -rf docs/_build/html docs/api/modules + +exec "$SPHINX_AUTOBUILD_EXE" docs docs/_build/html --open-browser --port 9000 --ignore docs/api/modules diff --git a/src/gh_action/Dockerfile b/src/gh_action/Dockerfile index 138b95a29..7ccbd3d40 100644 --- a/src/gh_action/Dockerfile +++ b/src/gh_action/Dockerfile @@ -1,5 +1,5 @@ # This Dockerfile is only for GitHub Actions -FROM python:3.13-bookworm +FROM python:3.14-slim-trixie ARG WORK_DIR="/opt/psr" WORKDIR ${WORK_DIR} @@ -16,6 +16,8 @@ RUN \ apt update && apt install -y --no-install-recommends \ # install git with git-lfs support git git-lfs \ + # install ssh client for git signing + openssh-client \ # install python cmodule / binary module build utilities python3-dev gcc make cmake cargo \ # Configure global pip diff --git a/src/gh_action/requirements.txt b/src/gh_action/requirements.txt index 9d0c27391..c25d6fc5a 100644 --- a/src/gh_action/requirements.txt +++ b/src/gh_action/requirements.txt @@ -1 +1 @@ -python-semantic-release == 10.3.2 +python-semantic-release == 10.5.3 diff --git a/src/semantic_release/changelog/context.py b/src/semantic_release/changelog/context.py index fe16ca2c2..563f6a3f4 100644 --- a/src/semantic_release/changelog/context.py +++ b/src/semantic_release/changelog/context.py @@ -119,7 +119,7 @@ def read_file(filepath: str) -> str: return rfd.read() except FileNotFoundError as err: - logging.warning(err) + logging.warning(str(err)) return "" diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 27b86be79..7a6fa26ef 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -10,7 +10,7 @@ import click import shellingham # type: ignore[import] from click_option_group import MutuallyExclusiveOptionGroup, optgroup -from git import Repo +from git import GitCommandError, Repo from requests import HTTPError from semantic_release.changelog.release_history import ReleaseHistory @@ -27,9 +27,14 @@ from semantic_release.enums import LevelBump from semantic_release.errors import ( BuildDistributionsError, + DetachedHeadGitError, GitCommitEmptyIndexError, + GitFetchError, InternalError, + LocalGitError, UnexpectedResponse, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, ) from semantic_release.gitproject import GitProject from semantic_release.globals import logger @@ -75,10 +80,13 @@ def is_forced_prerelease( ) -def last_released(repo_dir: Path, tag_format: str) -> tuple[Tag, Version] | None: +def last_released( + repo_dir: Path, tag_format: str, add_partial_tags: bool = False +) -> tuple[Tag, Version] | None: with Repo(str(repo_dir)) as git_repo: ts_and_vs = tags_and_versions( - git_repo.tags, VersionTranslator(tag_format=tag_format) + git_repo.tags, + VersionTranslator(tag_format=tag_format, add_partial_tags=add_partial_tags), ) return ts_and_vs[0] if ts_and_vs else None @@ -449,7 +457,11 @@ def version( # noqa: C901 if print_last_released or print_last_released_tag: # TODO: get tag format a better way if not ( - last_release := last_released(config.repo_dir, tag_format=config.tag_format) + last_release := last_released( + config.repo_dir, + tag_format=config.tag_format, + add_partial_tags=config.add_partial_tags, + ) ): logger.warning("No release tags found.") return @@ -470,6 +482,7 @@ def version( # noqa: C901 major_on_zero = runtime.major_on_zero no_verify = runtime.no_git_verify opts = runtime.global_cli_options + add_partial_tags = config.add_partial_tags gha_output = VersionGitHubActionsOutput( gh_client=hvcs_client if isinstance(hvcs_client, Github) else None, mode=( @@ -491,6 +504,17 @@ def version( # noqa: C901 logger.info("Forcing use of %s as the prerelease token", prerelease_token) translator.prerelease_token = prerelease_token + # Check if the repository is shallow and unshallow it if necessary + # This ensures we have the full history for commit analysis + project = GitProject( + directory=runtime.repo_dir, + commit_author=runtime.commit_author, + credential_masker=runtime.masker, + ) + if project.is_shallow_clone(): + logger.info("Repository is a shallow clone, converting to full clone...") + project.git_unshallow(noop=opts.noop) + # Only push if we're committing changes if push_changes and not commit_changes and not create_tag: logger.info("changes will not be pushed because --no-commit disables pushing") @@ -683,12 +707,6 @@ def version( # noqa: C901 license_name="" if not isinstance(license_cfg, str) else license_cfg, ) - project = GitProject( - directory=runtime.repo_dir, - commit_author=runtime.commit_author, - credential_masker=runtime.masker, - ) - # Preparing for committing changes; we always stage files even if we're not committing them in order to support a two-stage commit project.git_add(paths=all_paths_to_add, noop=opts.noop) if commit_changes: @@ -704,13 +722,14 @@ def version( # noqa: C901 ) except GitCommitEmptyIndexError: logger.info("No local changes to add to any commit, skipping") + commit_changes = False # Tag the version after potentially creating a new HEAD commit. # This way if no source code is modified, i.e. all metadata updates # are disabled, and the changelog generation is disabled or it's not # modified, then the HEAD commit will be tagged as a release commit # despite not being made by PSR - if commit_changes or create_tag: + if create_tag: project.git_tag( tag_name=new_version.as_tag(), message=new_version.as_tag(), @@ -727,6 +746,34 @@ def version( # noqa: C901 ) if commit_changes: + # Verify that the upstream branch has not changed before pushing + # This prevents conflicts if another commit was pushed while we were preparing the release + # We check HEAD~1 because we just made a release commit + try: + project.verify_upstream_unchanged( + local_ref="HEAD~1", + upstream_ref=config.remote.name, + remote_url=remote_url, + noop=opts.noop, + ) + except UpstreamBranchChangedError as exc: + click.echo(str(exc), err=True) + click.echo( + "Upstream branch has changed. Please pull the latest changes and try again.", + err=True, + ) + ctx.exit(1) + except ( + DetachedHeadGitError, + GitCommandError, + UnknownUpstreamBranchError, + GitFetchError, + LocalGitError, + ) as exc: + click.echo(str(exc), err=True) + click.echo("Unable to verify upstream due to error!", err=True) + ctx.exit(1) + # TODO: integrate into push branch with Repo(str(runtime.repo_dir)) as git_repo: active_branch = git_repo.active_branch.name @@ -744,6 +791,27 @@ def version( # noqa: C901 tag=new_version.as_tag(), noop=opts.noop, ) + # Create or update partial tags for releases + if add_partial_tags and not prerelease: + partial_tags = [new_version.as_major_tag(), new_version.as_minor_tag()] + # If build metadata is set, also retag the version without the metadata + if build_metadata: + partial_tags.append(new_version.as_patch_tag()) + + for partial_tag in partial_tags: + project.git_tag( + tag_name=partial_tag, + message=f"{partial_tag} => {new_version.as_tag()}", + isotimestamp=commit_date.isoformat(), + noop=opts.noop, + force=True, + ) + project.git_push_tag( + remote_url=remote_url, + tag=partial_tag, + noop=opts.noop, + force=True, + ) # Update GitHub Actions output value now that release has occurred gha_output.released = True diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 1d2057a48..514d76ef1 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -39,6 +39,7 @@ from semantic_release.commit_parser import ( AngularCommitParser, CommitParser, + ConventionalCommitMonorepoParser, ConventionalCommitParser, EmojiCommitParser, ParseResult, @@ -71,9 +72,10 @@ class HvcsClient(str, Enum): GITEA = "gitea" -_known_commit_parsers: Dict[str, type[CommitParser]] = { - "conventional": ConventionalCommitParser, +_known_commit_parsers: dict[str, type[CommitParser[Any, Any]]] = { "angular": AngularCommitParser, + "conventional": ConventionalCommitParser, + "conventional-monorepo": ConventionalCommitMonorepoParser, "emoji": EmojiCommitParser, "scipy": ScipyCommitParser, "tag": TagCommitParser, @@ -364,6 +366,7 @@ class RawConfig(BaseModel): remote: RemoteConfig = RemoteConfig() no_git_verify: bool = False tag_format: str = "v{version}" + add_partial_tags: bool = False publish: PublishConfig = PublishConfig() version_toml: Optional[Tuple[str, ...]] = None version_variables: Optional[Tuple[str, ...]] = None @@ -825,7 +828,9 @@ def from_raw_config( # noqa: C901 # version_translator version_translator = VersionTranslator( - tag_format=raw.tag_format, prerelease_token=branch_config.prerelease_token + tag_format=raw.tag_format, + prerelease_token=branch_config.prerelease_token, + add_partial_tags=raw.add_partial_tags, ) build_cmd_env = {} diff --git a/src/semantic_release/cli/masking_filter.py b/src/semantic_release/cli/masking_filter.py index f2e4f825f..255ab77db 100644 --- a/src/semantic_release/cli/masking_filter.py +++ b/src/semantic_release/cli/masking_filter.py @@ -62,9 +62,7 @@ def filter(self, record: LogRecord) -> bool: def mask(self, msg: str) -> str: if not isinstance(msg, str): - logger.debug( # type: ignore[unreachable] - "cannot mask object of type %s", type(msg) - ) + logger.debug("cannot mask object of type %s", type(msg)) return msg for mask, values in self._redact_patterns.items(): repl_string = ( diff --git a/src/semantic_release/commit_parser/__init__.py b/src/semantic_release/commit_parser/__init__.py index 740f4ae7f..15a96c176 100644 --- a/src/semantic_release/commit_parser/__init__.py +++ b/src/semantic_release/commit_parser/__init__.py @@ -7,6 +7,8 @@ AngularParserOptions, ) from semantic_release.commit_parser.conventional import ( + ConventionalCommitMonorepoParser, + ConventionalCommitMonorepoParserOptions, ConventionalCommitParser, ConventionalCommitParserOptions, ) @@ -28,3 +30,24 @@ ParseResult, ParseResultType, ) + +__all__ = [ + "CommitParser", + "ParserOptions", + "AngularCommitParser", + "AngularParserOptions", + "ConventionalCommitParser", + "ConventionalCommitParserOptions", + "ConventionalCommitMonorepoParser", + "ConventionalCommitMonorepoParserOptions", + "EmojiCommitParser", + "EmojiParserOptions", + "ScipyCommitParser", + "ScipyParserOptions", + "TagCommitParser", + "TagParserOptions", + "ParsedCommit", + "ParseError", + "ParseResult", + "ParseResultType", +] diff --git a/src/semantic_release/commit_parser/conventional/__init__.py b/src/semantic_release/commit_parser/conventional/__init__.py new file mode 100644 index 000000000..dd7d57d63 --- /dev/null +++ b/src/semantic_release/commit_parser/conventional/__init__.py @@ -0,0 +1,17 @@ +from semantic_release.commit_parser.conventional.options import ( + ConventionalCommitParserOptions, +) +from semantic_release.commit_parser.conventional.options_monorepo import ( + ConventionalCommitMonorepoParserOptions, +) +from semantic_release.commit_parser.conventional.parser import ConventionalCommitParser +from semantic_release.commit_parser.conventional.parser_monorepo import ( + ConventionalCommitMonorepoParser, +) + +__all__ = [ + "ConventionalCommitParser", + "ConventionalCommitParserOptions", + "ConventionalCommitMonorepoParser", + "ConventionalCommitMonorepoParserOptions", +] diff --git a/src/semantic_release/commit_parser/conventional/options.py b/src/semantic_release/commit_parser/conventional/options.py new file mode 100644 index 000000000..6bdb9739c --- /dev/null +++ b/src/semantic_release/commit_parser/conventional/options.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from itertools import zip_longest +from typing import Tuple + +from pydantic.dataclasses import dataclass + +from semantic_release.commit_parser._base import ParserOptions +from semantic_release.enums import LevelBump + + +@dataclass +class ConventionalCommitParserOptions(ParserOptions): + """Options dataclass for the ConventionalCommitParser.""" + + minor_tags: Tuple[str, ...] = ("feat",) + """Commit-type prefixes that should result in a minor release bump.""" + + patch_tags: Tuple[str, ...] = ("fix", "perf") + """Commit-type prefixes that should result in a patch release bump.""" + + other_allowed_tags: Tuple[str, ...] = ( + "build", + "chore", + "ci", + "docs", + "style", + "refactor", + "test", + ) + """Commit-type prefixes that are allowed but do not result in a version bump.""" + + allowed_tags: Tuple[str, ...] = ( + *minor_tags, + *patch_tags, + *other_allowed_tags, + ) + """ + All commit-type prefixes that are allowed. + + These are used to identify a valid commit message. If a commit message does not start with + one of these prefixes, it will not be considered a valid commit message. + """ + + default_bump_level: LevelBump = LevelBump.NO_RELEASE + """The minimum bump level to apply to valid commit message.""" + + parse_squash_commits: bool = True + """Toggle flag for whether or not to parse squash commits""" + + ignore_merge_commits: bool = True + """Toggle flag for whether or not to ignore merge commits""" + + @property + def tag_to_level(self) -> dict[str, LevelBump]: + """A mapping of commit tags to the level bump they should result in.""" + return self._tag_to_level + + def __post_init__(self) -> None: + self._tag_to_level: dict[str, LevelBump] = { + str(tag): level + for tag, level in [ + # we have to do a type ignore as zip_longest provides a type that is not specific enough + # for our expected output. Due to the empty second array, we know the first is always longest + # and that means no values in the first entry of the tuples will ever be a LevelBump. We + # apply a str() to make mypy happy although it will never happen. + *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), + *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), + *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), + ] + if "|" not in str(tag) + } diff --git a/src/semantic_release/commit_parser/conventional/options_monorepo.py b/src/semantic_release/commit_parser/conventional/options_monorepo.py new file mode 100644 index 000000000..241f7aaba --- /dev/null +++ b/src/semantic_release/commit_parser/conventional/options_monorepo.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from pathlib import Path +from re import compile as regexp, error as RegExpError # noqa: N812 +from typing import TYPE_CHECKING, Any, Iterable, Tuple + +from pydantic import Field, field_validator +from pydantic.dataclasses import dataclass + +# typing_extensions is for Python 3.8, 3.9, 3.10 compatibility +from typing_extensions import Annotated + +from semantic_release.commit_parser.conventional.options import ( + ConventionalCommitParserOptions, +) + +if TYPE_CHECKING: # pragma: no cover + pass + + +@dataclass +class ConventionalCommitMonorepoParserOptions(ConventionalCommitParserOptions): + # TODO: add example into the docstring + """Options dataclass for ConventionalCommitMonorepoParser.""" + + path_filters: Annotated[Tuple[str, ...], Field(validate_default=True)] = (".",) + """ + A set of relative paths to filter commits by. Only commits with file changes that + match these file paths or its subdirectories will be considered valid commits. + + Syntax is similar to .gitignore with file path globs and inverse file match globs + via `!` prefix. Paths should be relative to the current working directory. + """ + + scope_prefix: str = "" + """ + A prefix that will be striped from the scope when parsing commit messages. + + If set, it will cause unscoped commits to be ignored. Use this in tandem with + the `path_filters` option to filter commits by directory and scope. This will + be fed into a regular expression so you must escape any special characters that + are meaningful in regular expressions (e.g. `.`, `*`, `?`, `+`, etc.) if you want + to match them literally. + """ + + @field_validator("path_filters", mode="before") + @classmethod + def convert_strs_to_paths(cls, value: Any) -> tuple[str, ...]: + if isinstance(value, str): + return (value,) + + if isinstance(value, Path): + return (str(value),) + + if isinstance(value, Iterable): + results: list[str] = [] + for val in value: + if isinstance(val, (str, Path)): + results.append(str(Path(val))) + continue + + msg = f"Invalid type: {type(val)}, expected str or Path." + raise TypeError(msg) + + return tuple(results) + + msg = f"Invalid type: {type(value)}, expected str, Path, or Iterable." + raise TypeError(msg) + + @field_validator("path_filters", mode="after") + @classmethod + def resolve_path(cls, dir_path_strs: tuple[str, ...]) -> tuple[str, ...]: + return tuple( + ( + f"!{Path(str_path[1:]).expanduser().absolute().resolve()}" + # maintains the negation prefix if it exists + if str_path.startswith("!") + # otherwise, resolve the path normally + else str(Path(str_path).expanduser().absolute().resolve()) + ) + for str_path in dir_path_strs + ) + + @field_validator("scope_prefix", mode="after") + @classmethod + def validate_scope_prefix(cls, scope_prefix: str) -> str: + if not scope_prefix: + return "" + + # Allow the special case of a plain wildcard although it's not a valid regex + if scope_prefix == "*": + return ".*" + + try: + regexp(scope_prefix) + except RegExpError as err: + raise ValueError(f"Invalid regex {scope_prefix!r}") from err + + return scope_prefix diff --git a/src/semantic_release/commit_parser/conventional.py b/src/semantic_release/commit_parser/conventional/parser.py similarity index 74% rename from src/semantic_release/commit_parser/conventional.py rename to src/semantic_release/commit_parser/conventional/parser.py index 3cd50d9c7..5cab34c56 100644 --- a/src/semantic_release/commit_parser/conventional.py +++ b/src/semantic_release/commit_parser/conventional/parser.py @@ -1,16 +1,25 @@ from __future__ import annotations -import re from functools import reduce -from itertools import zip_longest -from re import compile as regexp +from logging import getLogger +from re import ( + DOTALL, + IGNORECASE, + MULTILINE, + Match as RegexMatch, + Pattern, + compile as regexp, + error as RegexError, # noqa: N812 +) from textwrap import dedent -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING, ClassVar from git.objects.commit import Commit -from pydantic.dataclasses import dataclass -from semantic_release.commit_parser._base import CommitParser, ParserOptions +from semantic_release.commit_parser._base import CommitParser +from semantic_release.commit_parser.conventional.options import ( + ConventionalCommitParserOptions, +) from semantic_release.commit_parser.token import ( ParsedCommit, ParsedMessageResult, @@ -25,16 +34,10 @@ ) from semantic_release.enums import LevelBump from semantic_release.errors import InvalidParserOptions -from semantic_release.globals import logger from semantic_release.helpers import sort_numerically, text_reducer -if TYPE_CHECKING: # pragma: no cover - from git.objects.commit import Commit - - -def _logged_parse_error(commit: Commit, error: str) -> ParseError: - logger.debug(error) - return ParseError(commit, error=error) +if TYPE_CHECKING: + pass # TODO: Remove from here, allow for user customization instead via options @@ -53,69 +56,6 @@ def _logged_parse_error(commit: Commit, error: str) -> ParseError: } -@dataclass -class ConventionalCommitParserOptions(ParserOptions): - """Options dataclass for the ConventionalCommitParser.""" - - minor_tags: Tuple[str, ...] = ("feat",) - """Commit-type prefixes that should result in a minor release bump.""" - - patch_tags: Tuple[str, ...] = ("fix", "perf") - """Commit-type prefixes that should result in a patch release bump.""" - - other_allowed_tags: Tuple[str, ...] = ( - "build", - "chore", - "ci", - "docs", - "style", - "refactor", - "test", - ) - """Commit-type prefixes that are allowed but do not result in a version bump.""" - - allowed_tags: Tuple[str, ...] = ( - *minor_tags, - *patch_tags, - *other_allowed_tags, - ) - """ - All commit-type prefixes that are allowed. - - These are used to identify a valid commit message. If a commit message does not start with - one of these prefixes, it will not be considered a valid commit message. - """ - - default_bump_level: LevelBump = LevelBump.NO_RELEASE - """The minimum bump level to apply to valid commit message.""" - - parse_squash_commits: bool = True - """Toggle flag for whether or not to parse squash commits""" - - ignore_merge_commits: bool = True - """Toggle flag for whether or not to ignore merge commits""" - - @property - def tag_to_level(self) -> dict[str, LevelBump]: - """A mapping of commit tags to the level bump they should result in.""" - return self._tag_to_level - - def __post_init__(self) -> None: - self._tag_to_level: dict[str, LevelBump] = { - str(tag): level - for tag, level in [ - # we have to do a type ignore as zip_longest provides a type that is not specific enough - # for our expected output. Due to the empty second array, we know the first is always longest - # and that means no values in the first entry of the tuples will ever be a LevelBump. We - # apply a str() to make mypy happy although it will never happen. - *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), - *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), - *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), - ] - if "|" not in str(tag) - } - - class ConventionalCommitParser( CommitParser[ParseResult, ConventionalCommitParserOptions] ): @@ -128,14 +68,57 @@ class ConventionalCommitParser( # TODO: Deprecate in lieu of get_default_options() parser_options = ConventionalCommitParserOptions + # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123) + mr_selector = regexp(r"[\t ]+\((?:pull request )?(?P[#!]\d+)\)[\t ]*$") + + issue_selector = regexp( + str.join( + "", + [ + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", + r"[\t ]+(?P.+)[\t ]*$", + ], + ), + flags=MULTILINE | IGNORECASE, + ) + + notice_selector = regexp(r"^NOTICE: (?P.+)$") + + common_commit_msg_filters: ClassVar[dict[str, tuple[Pattern[str], str]]] = { + "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"), + "git-header-commit": ( + regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=MULTILINE), + "", + ), + "git-header-author": ( + regexp(r"^[\t ]*Author: .+$\n?", flags=MULTILINE), + "", + ), + "git-header-date": ( + regexp(r"^[\t ]*Date: .+$\n?", flags=MULTILINE), + "", + ), + "git-squash-heading": ( + regexp( + r"^[\t ]*Squashed commit of the following:.*$\n?", + flags=MULTILINE, + ), + "", + ), + } + def __init__(self, options: ConventionalCommitParserOptions | None = None) -> None: super().__init__(options) + self._logger = getLogger( + str.join(".", [self.__module__, self.__class__.__name__]) + ) + try: commit_type_pattern = regexp( r"(?P%s)" % str.join("|", self.options.allowed_tags) ) - except re.error as err: + except RegexError as err: raise InvalidParserOptions( str.join( "\n", @@ -167,45 +150,11 @@ def __init__(self, options: ConventionalCommitParserOptions | None = None) -> No r"(?:\n\n(?P.+))?", # commit body ], ), - flags=re.DOTALL, + flags=DOTALL, ) - # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123) - self.mr_selector = regexp( - r"[\t ]+\((?:pull request )?(?P[#!]\d+)\)[\t ]*$" - ) - self.issue_selector = regexp( - str.join( - "", - [ - r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", - r"[\t ]+(?P.+)[\t ]*$", - ], - ), - flags=re.MULTILINE | re.IGNORECASE, - ) - self.notice_selector = regexp(r"^NOTICE: (?P.+)$") - self.filters = { - "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"), - "git-header-commit": ( - regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=re.MULTILINE), - "", - ), - "git-header-author": ( - regexp(r"^[\t ]*Author: .+$\n?", flags=re.MULTILINE), - "", - ), - "git-header-date": ( - regexp(r"^[\t ]*Date: .+$\n?", flags=re.MULTILINE), - "", - ), - "git-squash-heading": ( - regexp( - r"^[\t ]*Squashed commit of the following:.*$\n?", - flags=re.MULTILINE, - ), - "", - ), + self.filters: dict[str, tuple[Pattern[str], str]] = { + **self.common_commit_msg_filters, "git-squash-commit-prefix": ( regexp( str.join( @@ -215,17 +164,20 @@ def __init__(self, options: ConventionalCommitParserOptions | None = None) -> No commit_type_pattern.pattern + r"\b", # prior to commit type ], ), - flags=re.MULTILINE, + flags=MULTILINE, ), # move commit type to the start of the line r"\1", ), } - @staticmethod - def get_default_options() -> ConventionalCommitParserOptions: + def get_default_options(self) -> ConventionalCommitParserOptions: return ConventionalCommitParserOptions() + def log_parse_error(self, commit: Commit, error: str) -> ParseError: + self._logger.debug(error) + return ParseError(commit, error=error) + def commit_body_components_separator( self, accumulator: dict[str, list[str]], text: str ) -> dict[str, list[str]]: @@ -267,14 +219,20 @@ def commit_body_components_separator( return accumulator def parse_message(self, message: str) -> ParsedMessageResult | None: - if not (parsed := self.commit_msg_pattern.match(message)): - return None + return ( + self.create_parsed_message_result(match) + if (match := self.commit_msg_pattern.match(message)) + else None + ) - parsed_break = parsed.group("break") - parsed_scope = parsed.group("scope") or "" - parsed_subject = parsed.group("subject") - parsed_text = parsed.group("text") - parsed_type = parsed.group("type") + def create_parsed_message_result( + self, match: RegexMatch[str] + ) -> ParsedMessageResult: + parsed_break = match.group("break") + parsed_scope = match.group("scope") or "" + parsed_subject = match.group("subject") + parsed_text = match.group("text") + parsed_type = match.group("type") linked_merge_request = "" if mr_match := self.mr_selector.search(parsed_subject): @@ -322,7 +280,7 @@ def is_merge_commit(commit: Commit) -> bool: def parse_commit(self, commit: Commit) -> ParseResult: if not (parsed_msg_result := self.parse_message(force_str(commit.message))): - return _logged_parse_error( + return self.log_parse_error( commit, f"Unable to parse commit message: {commit.message!r}", ) @@ -342,7 +300,7 @@ def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: will be returned as a list of a single ParseResult. """ if self.options.ignore_merge_commits and self.is_merge_commit(commit): - return _logged_parse_error( + return self.log_parse_error( commit, "Ignoring merge commit: %s" % commit.hexsha[:8] ) diff --git a/src/semantic_release/commit_parser/conventional/parser_monorepo.py b/src/semantic_release/commit_parser/conventional/parser_monorepo.py new file mode 100644 index 000000000..18a938c43 --- /dev/null +++ b/src/semantic_release/commit_parser/conventional/parser_monorepo.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +import os +from fnmatch import fnmatch +from logging import getLogger +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath +from re import DOTALL, compile as regexp, error as RegexError # noqa: N812 +from typing import TYPE_CHECKING + +from semantic_release.commit_parser._base import CommitParser +from semantic_release.commit_parser.conventional.options import ( + ConventionalCommitParserOptions, +) +from semantic_release.commit_parser.conventional.options_monorepo import ( + ConventionalCommitMonorepoParserOptions, +) +from semantic_release.commit_parser.conventional.parser import ConventionalCommitParser +from semantic_release.commit_parser.token import ( + ParsedCommit, + ParsedMessageResult, + ParseError, + ParseResult, +) +from semantic_release.commit_parser.util import force_str +from semantic_release.errors import InvalidParserOptions + +if TYPE_CHECKING: # pragma: no cover + from git.objects.commit import Commit + + +class ConventionalCommitMonorepoParser( + CommitParser[ParseResult, ConventionalCommitMonorepoParserOptions] +): + # TODO: Remove for v11 compatibility, get_default_options() will be called instead + parser_options = ConventionalCommitMonorepoParserOptions + + def __init__( + self, options: ConventionalCommitMonorepoParserOptions | None = None + ) -> None: + super().__init__(options) + + try: + commit_scope_pattern = regexp( + r"\(" + self.options.scope_prefix + r"(?P[^\n]+)?\)", + ) + except RegexError as err: + raise InvalidParserOptions( + str.join( + "\n", + [ + f"Invalid options for {self.__class__.__name__}", + "Unable to create regular expression from configured scope_prefix.", + "Please check the configured scope_prefix and remove or escape any regular expression characters.", + ], + ) + ) from err + + try: + commit_type_pattern = regexp( + r"(?P%s)" % str.join("|", self.options.allowed_tags) + ) + except RegexError as err: + raise InvalidParserOptions( + str.join( + "\n", + [ + f"Invalid options for {self.__class__.__name__}", + "Unable to create regular expression from configured commit-types.", + "Please check the configured commit-types and remove or escape any regular expression characters.", + ], + ) + ) from err + + # This regular expression includes scope prefix into the pattern and forces a scope to be present + # PSR will match the full scope but we don't include it in the scope match, + # which implicitly strips it from being included in the returned scope. + self._strict_scope_pattern = regexp( + str.join( + "", + [ + r"^" + commit_type_pattern.pattern, + commit_scope_pattern.pattern, + r"(?P!)?:\s+", + r"(?P[^\n]+)", + r"(?:\n\n(?P.+))?", # commit body + ], + ), + flags=DOTALL, + ) + + self._optional_scope_pattern = regexp( + str.join( + "", + [ + r"^" + commit_type_pattern.pattern, + r"(?:\((?P[^\n]+)\))?", + r"(?P!)?:\s+", + r"(?P[^\n]+)", + r"(?:\n\n(?P.+))?", # commit body + ], + ), + flags=DOTALL, + ) + + file_select_filters, file_ignore_filters = self._process_path_filter_options( + self.options.path_filters + ) + self._file_selection_filters: list[str] = file_select_filters + self._file_ignore_filters: list[str] = file_ignore_filters + + self._logger = getLogger( + str.join(".", [self.__module__, self.__class__.__name__]) + ) + + self._base_parser = ConventionalCommitParser( + options=ConventionalCommitParserOptions( + **{ + k: getattr(self.options, k) + for k in ConventionalCommitParserOptions().__dataclass_fields__ + } + ) + ) + + def get_default_options(self) -> ConventionalCommitMonorepoParserOptions: + return ConventionalCommitMonorepoParserOptions() + + @staticmethod + def _process_path_filter_options( # noqa: C901 + path_filters: tuple[str, ...], + ) -> tuple[list[str], list[str]]: + file_ignore_filters: list[str] = [] + file_selection_filters: list[str] = [] + unique_selection_filters: set[str] = set() + unique_ignore_filters: set[str] = set() + + for str_path in path_filters: + str_filter = str_path[1:] if str_path.startswith("!") else str_path + filter_list = ( + file_ignore_filters + if str_path.startswith("!") + else file_selection_filters + ) + unique_cache = ( + unique_ignore_filters + if str_path.startswith("!") + else unique_selection_filters + ) + + # Since fnmatch is not too flexible, we will expand the path filters to include the name and any subdirectories + # as this is how gitignore is interpreted. Possible scenarios: + # | Input | Path Normalization | Filter List | + # | ---------- | ------------------ | ------------------------- | + # | / | / | /** | done + # | /./ | / | /** | done + # | /** | /** | /** | done + # | /./** | /** | /** | done + # | /* | /* | /* | done + # | . | . | ./** | done + # | ./ | . | ./** | done + # | ././ | . | ./** | done + # | ./** | ./** | ./** | done + # | ./* | ./* | ./* | done + # | .. | .. | ../** | done + # | ../ | .. | ../** | done + # | ../** | ../** | ../** | done + # | ../* | ../* | ../* | done + # | ../.. | ../.. | ../../** | done + # | ../../ | ../../ | ../../** | done + # | ../../docs | ../../docs | ../../docs, ../../docs/** | done + # | src | src | src, src/** | done + # | src/ | src | src/** | done + # | src/* | src/* | src/* | done + # | src/** | src/** | src/** | done + # | /src | /src | /src, /src/** | done + # | /src/ | /src | /src/** | done + # | /src/** | /src/** | /src/** | done + # | /src/* | /src/* | /src/* | done + # | ../d/f.txt | ../d/f.txt | ../d/f.txt, ../d/f.txt/** | done + # This expansion will occur regardless of the negation prefix + + os_path: PurePath | PurePosixPath | PureWindowsPath = PurePath(str_filter) + + if r"\\" in str_filter: + # Windows paths were given so we convert them to posix paths + os_path = PureWindowsPath(str_filter) + os_path = ( + PureWindowsPath( + os_path.root, *os_path.parts[1:] + ) # drop any drive letter + if os_path.is_absolute() + else os_path + ) + os_path = PurePosixPath(os_path.as_posix()) + + path_normalized = str(os_path) + if path_normalized == str( + Path(".").absolute().root + ) or path_normalized == str(Path("/**")): + path_normalized = "/**" + + elif path_normalized == str(Path("/*")): + pass + + elif path_normalized == str(Path(".")) or path_normalized == str( + Path("./**") + ): + path_normalized = "./**" + + elif path_normalized == str(Path("./*")): + path_normalized = "./*" + + elif path_normalized == str(Path("..")) or path_normalized == str( + Path("../**") + ): + path_normalized = "../**" + + elif path_normalized == str(Path("../*")): + path_normalized = "../*" + + elif path_normalized.endswith(("..", "../**")): + path_normalized = f"{path_normalized.rstrip('*')}/**" + + elif str_filter.endswith(os.sep): + # If the path ends with a separator, it is a directory, so we add the directory and all subdirectories + path_normalized = f"{path_normalized}/**" + + elif not path_normalized.endswith("*"): + all_subdirs = f"{path_normalized}/**" + if all_subdirs not in unique_cache: + unique_cache.add(all_subdirs) + filter_list.append(all_subdirs) + # And fall through to add the path as is + + # END IF + + # Add the normalized path to the filter list if it is not already present + if path_normalized not in unique_cache: + unique_cache.add(path_normalized) + filter_list.append(path_normalized) + + return file_selection_filters, file_ignore_filters + + def logged_parse_error(self, commit: Commit, error: str) -> ParseError: + self._logger.debug(error) + return ParseError(commit, error=error) + + def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: + if self.options.ignore_merge_commits and self._base_parser.is_merge_commit( + commit + ): + return self._base_parser.log_parse_error( + commit, "Ignoring merge commit: %s" % commit.hexsha[:8] + ) + + separate_commits: list[Commit] = ( + self._base_parser.unsquash_commit(commit) + if self.options.parse_squash_commits + else [commit] + ) + + # Parse each commit individually if there were more than one + parsed_commits: list[ParseResult] = list( + map(self.parse_commit, separate_commits) + ) + + def add_linked_merge_request( + parsed_result: ParseResult, mr_number: str + ) -> ParseResult: + return ( + parsed_result + if not isinstance(parsed_result, ParsedCommit) + else ParsedCommit( + **{ + **parsed_result._asdict(), + "linked_merge_request": mr_number, + } + ) + ) + + # TODO: improve this for other VCS systems other than GitHub & BitBucket + # Github works as the first commit in a squash merge commit has the PR number + # appended to the first line of the commit message + lead_commit = next(iter(parsed_commits)) + + if isinstance(lead_commit, ParsedCommit) and lead_commit.linked_merge_request: + # If the first commit has linked merge requests, assume all commits + # are part of the same PR and add the linked merge requests to all + # parsed commits + parsed_commits = [ + lead_commit, + *map( + lambda parsed_result, mr=lead_commit.linked_merge_request: ( # type: ignore[misc] + add_linked_merge_request(parsed_result, mr) + ), + parsed_commits[1:], + ), + ] + + elif isinstance(lead_commit, ParseError) and ( + mr_match := self._base_parser.mr_selector.search( + force_str(lead_commit.message) + ) + ): + # Handle BitBucket Squash Merge Commits (see #1085), which have non angular commit + # format but include the PR number in the commit subject that we want to extract + linked_merge_request = mr_match.group("mr_number") + + # apply the linked MR to all commits + parsed_commits = [ + add_linked_merge_request(parsed_result, linked_merge_request) + for parsed_result in parsed_commits + ] + + return parsed_commits + + def parse_message( + self, message: str, strict_scope: bool = False + ) -> ParsedMessageResult | None: + if ( + not (parsed_match := self._strict_scope_pattern.match(message)) + and strict_scope + ): + return None + + if not parsed_match and not ( + parsed_match := self._optional_scope_pattern.match(message) + ): + return None + + return self._base_parser.create_parsed_message_result(parsed_match) + + def parse_commit(self, commit: Commit) -> ParseResult: + """Attempt to parse the commit message with a regular expression into a ParseResult.""" + # Multiple scenarios to consider when parsing a commit message [Truth table]: + # ======================================================================================================= + # | || INPUTS || | + # | # ||------------------------+----------------+--------------|| Result | + # | || Example Commit Message | Relevant Files | Scope Prefix || | + # |----||------------------------+----------------+--------------||-------------------------------------| + # | 1 || type(prefix-cli): msg | yes | "prefix-" || ParsedCommit | + # | 2 || type(prefix-cli): msg | yes | "" || ParsedCommit | + # | 3 || type(prefix-cli): msg | no | "prefix-" || ParsedCommit | + # | 4 || type(prefix-cli): msg | no | "" || ParseError[No files] | + # | 5 || type(scope-cli): msg | yes | "prefix-" || ParsedCommit | + # | 6 || type(scope-cli): msg | yes | "" || ParsedCommit | + # | 7 || type(scope-cli): msg | no | "prefix-" || ParseError[No files & wrong scope] | + # | 8 || type(scope-cli): msg | no | "" || ParseError[No files] | + # | 9 || type(cli): msg | yes | "prefix-" || ParsedCommit | + # | 10 || type(cli): msg | yes | "" || ParsedCommit | + # | 11 || type(cli): msg | no | "prefix-" || ParseError[No files & wrong scope] | + # | 12 || type(cli): msg | no | "" || ParseError[No files] | + # | 13 || type: msg | yes | "prefix-" || ParsedCommit | + # | 14 || type: msg | yes | "" || ParsedCommit | + # | 15 || type: msg | no | "prefix-" || ParseError[No files & wrong scope] | + # | 16 || type: msg | no | "" || ParseError[No files] | + # | 17 || non-conventional msg | yes | "prefix-" || ParseError[Invalid Syntax] | + # | 18 || non-conventional msg | yes | "" || ParseError[Invalid Syntax] | + # | 19 || non-conventional msg | no | "prefix-" || ParseError[Invalid Syntax] | + # | 20 || non-conventional msg | no | "" || ParseError[Invalid Syntax] | + # ======================================================================================================= + + # Initial Logic Flow: + # [1] When there are no relevant files and a scope prefix is defined, we enforce a strict scope + # [2] When there are no relevant files and no scope prefix is defined, we parse scoped or unscoped commits + # [3] When there are relevant files, we parse scoped or unscoped commits regardless of any defined prefix + has_relevant_changed_files = self._has_relevant_changed_files(commit) + strict_scope = bool( + not has_relevant_changed_files and self.options.scope_prefix + ) + pmsg_result = self.parse_message( + message=force_str(commit.message), + strict_scope=strict_scope, + ) + + if pmsg_result and (has_relevant_changed_files or strict_scope): + self._logger.debug( + "commit %s introduces a %s level_bump", + commit.hexsha[:8], + pmsg_result.bump, + ) + + return ParsedCommit.from_parsed_message_result(commit, pmsg_result) + + if pmsg_result and not has_relevant_changed_files: + return self.logged_parse_error( + commit, + f"Commit {commit.hexsha[:7]} has no changed files matching the path filter(s)", + ) + + if strict_scope and self.parse_message(str(commit.message), strict_scope=False): + return self.logged_parse_error( + commit, + str.join( + " and ", + [ + f"Commit {commit.hexsha[:7]} has no changed files matching the path filter(s)", + f"the scope does not match scope prefix '{self.options.scope_prefix}'", + ], + ), + ) + + return self.logged_parse_error( + commit, + f"Format Mismatch! Unable to parse commit message: {commit.message!r}", + ) + + def unsquash_commit_message(self, message: str) -> list[str]: + return self._base_parser.unsquash_commit_message(message) + + def _has_relevant_changed_files(self, commit: Commit) -> bool: + # Extract git root from commit + git_root = ( + Path(commit.repo.working_tree_dir or commit.repo.working_dir) + .absolute() + .resolve() + ) + + cwd = Path.cwd().absolute().resolve() + + rel_cwd = cwd.relative_to(git_root) if git_root in cwd.parents else Path(".") + + sandboxed_selection_filters: list[str] = [ + str(file_filter) + for file_filter in ( + ( + git_root / select_filter.rstrip("/") + if Path(select_filter).is_absolute() + else git_root / rel_cwd / select_filter + ) + for select_filter in self._file_selection_filters + ) + if git_root in file_filter.parents + ] + + sandboxed_ignore_filters: list[str] = [ + str(file_filter) + for file_filter in ( + ( + git_root / ignore_filter.rstrip("/") + if Path(ignore_filter).is_absolute() + else git_root / rel_cwd / ignore_filter + ) + for ignore_filter in self._file_ignore_filters + ) + if git_root in file_filter.parents + ] + + # Check if the changed files of the commit that match the path filters + for full_path in iter( + str(git_root / rel_git_path) for rel_git_path in commit.stats.files + ): + # Check if the filepath matches any of the file selection filters + if not any( + fnmatch(full_path, select_filter) + for select_filter in sandboxed_selection_filters + ): + continue + + # Pass filter matches, so now evaluate if it is supposed to be ignored + if not any( + fnmatch(full_path, ignore_filter) + for ignore_filter in sandboxed_ignore_filters + ): + # No ignore filter matched, so it must be a relevant file + return True + + return False diff --git a/src/semantic_release/errors.py b/src/semantic_release/errors.py index 954e85d20..600106906 100644 --- a/src/semantic_release/errors.py +++ b/src/semantic_release/errors.py @@ -106,3 +106,19 @@ class GitTagError(SemanticReleaseBaseError): class GitPushError(SemanticReleaseBaseError): """Raised when there is a failure to push to the git remote.""" + + +class GitFetchError(SemanticReleaseBaseError): + """Raised when there is a failure to fetch from the git remote.""" + + +class LocalGitError(SemanticReleaseBaseError): + """Raised when there is a failure with local git operations.""" + + +class UnknownUpstreamBranchError(SemanticReleaseBaseError): + """Raised when the upstream branch cannot be determined.""" + + +class UpstreamBranchChangedError(SemanticReleaseBaseError): + """Raised when the upstream branch has changed before pushing.""" diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index 0e4592599..cc86d33b5 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -12,11 +12,16 @@ from semantic_release.cli.masking_filter import MaskingFilter from semantic_release.cli.util import indented, noop_report from semantic_release.errors import ( + DetachedHeadGitError, GitAddError, GitCommitEmptyIndexError, GitCommitError, + GitFetchError, GitPushError, GitTagError, + LocalGitError, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, ) from semantic_release.globals import logger @@ -85,6 +90,42 @@ def is_dirty(self) -> bool: with Repo(str(self.project_root)) as repo: return repo.is_dirty() + def is_shallow_clone(self) -> bool: + """ + Check if the repository is a shallow clone. + + :return: True if the repository is a shallow clone, False otherwise + """ + with Repo(str(self.project_root)) as repo: + shallow_file = Path(repo.git_dir, "shallow") + return shallow_file.exists() + + def git_unshallow(self, noop: bool = False) -> None: + """ + Convert a shallow clone to a full clone by fetching the full history. + + :param noop: Whether or not to actually run the unshallow command + """ + if noop: + noop_report("would have run:\n" " git fetch --unshallow") + return + + with Repo(str(self.project_root)) as repo: + try: + self.logger.info("Converting shallow clone to full clone...") + repo.git.fetch("--unshallow") + self.logger.info("Repository unshallowed successfully") + except GitCommandError as err: + # If the repository is already a full clone, git fetch --unshallow will fail + # with "fatal: --unshallow on a complete repository does not make sense" + # We can safely ignore this error by checking the stderr message + stderr = str(err.stderr) if err.stderr else "" + if "does not make sense" in stderr or "complete repository" in stderr: + self.logger.debug("Repository is already a full clone") + else: + self.logger.exception(str(err)) + raise + def git_add( self, paths: Sequence[Path | str], @@ -197,7 +238,12 @@ def git_commit( raise GitCommitError("Failed to commit changes") from err def git_tag( - self, tag_name: str, message: str, isotimestamp: str, noop: bool = False + self, + tag_name: str, + message: str, + isotimestamp: str, + force: bool = False, + noop: bool = False, ) -> None: try: datetime.fromisoformat(isotimestamp) @@ -207,21 +253,25 @@ def git_tag( if noop: command = str.join( " ", - [ - f"GIT_COMMITTER_DATE={isotimestamp}", - *( - [ - f"GIT_AUTHOR_NAME={self._commit_author.name}", - f"GIT_AUTHOR_EMAIL={self._commit_author.email}", - f"GIT_COMMITTER_NAME={self._commit_author.name}", - f"GIT_COMMITTER_EMAIL={self._commit_author.email}", - ] - if self._commit_author - else [""] - ), - f"git tag -a {tag_name} -m '{message}'", - ], - ) + filter( + None, + [ + f"GIT_COMMITTER_DATE={isotimestamp}", + *( + [ + f"GIT_AUTHOR_NAME={self._commit_author.name}", + f"GIT_AUTHOR_EMAIL={self._commit_author.email}", + f"GIT_COMMITTER_NAME={self._commit_author.name}", + f"GIT_COMMITTER_EMAIL={self._commit_author.email}", + ] + if self._commit_author + else [""] + ), + f"git tag -a {tag_name} -m '{message}'", + "--force" if force else "", + ], + ), + ).strip() noop_report( indented( @@ -238,7 +288,7 @@ def git_tag( {"GIT_COMMITTER_DATE": isotimestamp}, ): try: - repo.git.tag("-a", tag_name, m=message) + repo.git.tag(tag_name, a=True, m=message, force=force) except GitCommandError as err: self.logger.exception(str(err)) raise GitTagError(f"Failed to create tag ({tag_name})") from err @@ -264,13 +314,15 @@ def git_push_branch(self, remote_url: str, branch: str, noop: bool = False) -> N f"Failed to push branch ({branch}) to remote" ) from err - def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: + def git_push_tag( + self, remote_url: str, tag: str, noop: bool = False, force: bool = False + ) -> None: if noop: noop_report( indented( f"""\ would have run: - git push {self._cred_masker.mask(remote_url)} tag {tag} + git push {self._cred_masker.mask(remote_url)} tag {tag} {"--force" if force else ""} """ # noqa: E501 ) ) @@ -278,7 +330,166 @@ def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: with Repo(str(self.project_root)) as repo: try: - repo.git.push(remote_url, "tag", tag) + repo.git.push(remote_url, "tag", tag, force=force) except GitCommandError as err: self.logger.exception(str(err)) raise GitPushError(f"Failed to push tag ({tag}) to remote") from err + + def verify_upstream_unchanged( # noqa: C901 + self, + local_ref: str = "HEAD", + upstream_ref: str = "origin", + remote_url: str | None = None, + noop: bool = False, + ) -> None: + """ + Verify that the upstream branch has not changed since the given local reference. + + :param local_ref: The local reference to compare against upstream (default: HEAD) + :param upstream_ref: The name of the upstream remote or specific remote branch (default: origin) + :param remote_url: Optional authenticated remote URL to use for fetching (default: None, uses configured remote) + :param noop: Whether to skip the actual verification (for dry-run mode) + + :raises UpstreamBranchChangedError: If the upstream branch has changed + """ + if not local_ref.strip(): + raise ValueError("Local reference cannot be empty") + if not upstream_ref.strip(): + raise ValueError("Upstream reference cannot be empty") + + if noop: + noop_report( + indented( + """\ + would have verified that upstream branch has not changed + """ + ) + ) + return + + with Repo(str(self.project_root)) as repo: + # Get the current active branch + try: + active_branch = repo.active_branch + except TypeError: + # When in detached HEAD state, active_branch raises TypeError + err_msg = ( + "Repository is in detached HEAD state, cannot verify upstream state" + ) + raise DetachedHeadGitError(err_msg) from None + + # Get the tracking branch (upstream branch) + if (tracking_branch := active_branch.tracking_branch()) is not None: + upstream_full_ref_name = tracking_branch.name + self.logger.info("Upstream branch name: %s", upstream_full_ref_name) + else: + # If no tracking branch is set, derive it + upstream_name = ( + upstream_ref.strip() + if upstream_ref.find("/") == -1 + else upstream_ref.strip().split("/", maxsplit=1)[0] + ) + + if not repo.remotes or upstream_name not in repo.remotes: + err_msg = "No remote found; cannot verify upstream state!" + raise UnknownUpstreamBranchError(err_msg) + + upstream_full_ref_name = ( + f"{upstream_name}/{active_branch.name}" + if upstream_ref.find("/") == -1 + else upstream_ref.strip() + ) + + if upstream_full_ref_name not in repo.refs: + err_msg = f"No upstream branch found for '{active_branch.name}'; cannot verify upstream state!" + raise UnknownUpstreamBranchError(err_msg) + + # Extract the remote name from the tracking branch + # tracking_branch.name is in the format "remote/branch" + remote_name, remote_branch_name = upstream_full_ref_name.split( + "/", maxsplit=1 + ) + remote_ref_obj = repo.remotes[remote_name] + + # Fetch the latest changes from the remote + self.logger.info("Fetching latest changes from remote '%s'", remote_name) + try: + # Check if we should use authenticated URL for fetch + # Only use remote_url if: + # 1. It's provided and different from the configured remote URL + # 2. It contains authentication credentials (@ symbol) + # 3. The configured remote is NOT a local path, file:// URL, or test URL (example.com) + # This ensures we don't break tests or local development + configured_url = remote_ref_obj.url + is_local_or_test_remote = ( + configured_url.startswith(("file://", "/", "C:/", "H:/")) + or "example.com" in configured_url + or not configured_url.startswith( + ( + "https://", + "http://", + "git://", + "git@", + "ssh://", + "git+ssh://", + ) + ) + ) + + use_authenticated_fetch = ( + remote_url + and "@" in remote_url + and remote_url != configured_url + and not is_local_or_test_remote + ) + + if use_authenticated_fetch: + # Use authenticated remote URL for fetch + # Fetch the remote branch and update the local tracking ref + repo.git.fetch( + remote_url, + f"refs/heads/{remote_branch_name}:refs/remotes/{upstream_full_ref_name}", + ) + else: + # Use the default remote configuration for local paths, + # file:// URLs, test URLs, or when no authentication is needed + remote_ref_obj.fetch() + except GitCommandError as err: + self.logger.exception(str(err)) + err_msg = f"Failed to fetch from remote '{remote_name}'" + raise GitFetchError(err_msg) from err + + # Get the SHA of the upstream branch + try: + upstream_commit_ref = remote_ref_obj.refs[remote_branch_name].commit + upstream_sha = upstream_commit_ref.hexsha + except AttributeError as err: + self.logger.exception(str(err)) + err_msg = f"Unable to determine upstream branch SHA for '{upstream_full_ref_name}'" + raise GitFetchError(err_msg) from err + + # Get the SHA of the specified ref (default: HEAD) + try: + local_commit = repo.commit(repo.git.rev_parse(local_ref)) + except GitCommandError as err: + self.logger.exception(str(err)) + err_msg = f"Unable to determine the SHA for local ref '{local_ref}'" + raise LocalGitError(err_msg) from err + + # Compare the two SHAs + if local_commit.hexsha != upstream_sha and not any( + commit.hexsha == upstream_sha for commit in local_commit.iter_parents() + ): + err_msg = str.join( + "\n", + ( + f"[LOCAL SHA] {local_commit.hexsha} != {upstream_sha} [UPSTREAM SHA].", + f"Upstream branch '{upstream_full_ref_name}' has changed!", + ), + ) + raise UpstreamBranchChangedError(err_msg) + + self.logger.info( + "Verified upstream branch '%s' has not changed", + upstream_full_ref_name, + ) diff --git a/src/semantic_release/version/declarations/pattern.py b/src/semantic_release/version/declarations/pattern.py index f08c208a4..4d285b5a1 100644 --- a/src/semantic_release/version/declarations/pattern.py +++ b/src/semantic_release/version/declarations/pattern.py @@ -228,8 +228,8 @@ def from_string_definition( # Negative lookbehind to ensure we don't match part of a variable name f"""(?x)(?P['"])?(?['"])?{value_replace_pattern_str}(?P=quote2)?""", ], diff --git a/src/semantic_release/version/translator.py b/src/semantic_release/version/translator.py index 6340701da..5026fd089 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -1,12 +1,16 @@ from __future__ import annotations -import re +from re import VERBOSE, compile as regexp, escape as regex_escape +from typing import TYPE_CHECKING from semantic_release.const import SEMVER_REGEX from semantic_release.globals import logger from semantic_release.helpers import check_tag_format from semantic_release.version.version import Version +if TYPE_CHECKING: + from re import Pattern + class VersionTranslator: """ @@ -17,7 +21,7 @@ class VersionTranslator: _VERSION_REGEX = SEMVER_REGEX @classmethod - def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]: + def _invert_tag_format_to_re(cls, tag_format: str) -> Pattern[str]: r""" Unpick the "tag_format" format string and create a regex which can be used to convert a tag to a version string. @@ -31,9 +35,11 @@ def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]: >>> assert m is not None >>> assert m.expand(r"\g") == version """ - pat = re.compile( - tag_format.replace(r"{version}", r"(?P.*)"), - flags=re.VERBOSE, + pat = regexp( + regex_escape(tag_format).replace( + regex_escape(r"{version}"), r"(?P.+)" + ), + flags=VERBOSE, ) logger.debug("inverted tag_format %r to %r", tag_format, pat.pattern) return pat @@ -42,11 +48,19 @@ def __init__( self, tag_format: str = "v{version}", prerelease_token: str = "rc", # noqa: S107 + add_partial_tags: bool = False, ) -> None: check_tag_format(tag_format) self.tag_format = tag_format self.prerelease_token = prerelease_token + self.add_partial_tags = add_partial_tags self.from_tag_re = self._invert_tag_format_to_re(self.tag_format) + self.partial_tag_re = regexp( + regex_escape(tag_format).replace( + regex_escape(r"{version}"), r"[0-9]+(\.(0|[1-9][0-9]*))?$" + ), + flags=VERBOSE, + ) def from_string(self, version_str: str) -> Version: """ @@ -69,6 +83,10 @@ def from_tag(self, tag: str) -> Version | None: tag_match = self.from_tag_re.match(tag) if not tag_match: return None + if self.add_partial_tags: + partial_tag_match = self.partial_tag_re.match(tag) + if partial_tag_match: + return None raw_version_str = tag_match.group("version") return self.from_string(raw_version_str) diff --git a/src/semantic_release/version/version.py b/src/semantic_release/version/version.py index 032596e4a..3e97be9fe 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -203,6 +203,15 @@ def __repr__(self) -> str: def as_tag(self) -> str: return self.tag_format.format(version=str(self)) + def as_major_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}") + + def as_minor_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}.{self.minor}") + + def as_patch_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}.{self.minor}.{self.patch}") + def as_semver_tag(self) -> str: return f"v{self!s}" diff --git a/tests/conftest.py b/tests/conftest.py index 7a00b8dad..df4e2f82d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -206,6 +206,10 @@ def _run_cli( invoke_kwargs: dict[str, Any] | None = None, ) -> Result: from semantic_release.cli.commands.main import main + from semantic_release.globals import logger + + # Prevent logs from being propagated to the root logger (pytest) + logger.propagate = False cli_runner = CliRunner(mix_stderr=False) env_vars = {**clean_os_environment, **(env or {})} @@ -213,7 +217,11 @@ def _run_cli( with mock.patch.dict(os.environ, env_vars, clear=True): # run the CLI with the provided arguments - return cli_runner.invoke(main, args=args, **(invoke_kwargs or {})) + result = cli_runner.invoke(main, args=args, **(invoke_kwargs or {})) + # Force the output to be printed to stdout which will be captured by pytest + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + return result return _run_cli diff --git a/tests/const.py b/tests/const.py index c7cc0a8b4..69a7ca778 100644 --- a/tests/const.py +++ b/tests/const.py @@ -11,6 +11,9 @@ class RepoActionStep(str, Enum): CONFIGURE = "CONFIGURE" + CONFIGURE_MONOREPO = "CONFIGURE_MONOREPO" + CREATE_MONOREPO = "CREATE_MONOREPO" + CHANGE_DIRECTORY = "CHANGE_DIRECTORY" WRITE_CHANGELOGS = "WRITE_CHANGELOGS" GIT_CHECKOUT = "GIT_CHECKOUT" GIT_COMMIT = "GIT_COMMIT" diff --git a/tests/e2e/cmd_changelog/test_changelog.py b/tests/e2e/cmd_changelog/test_changelog.py index 757e1316b..0ff1bc710 100644 --- a/tests/e2e/cmd_changelog/test_changelog.py +++ b/tests/e2e/cmd_changelog/test_changelog.py @@ -76,7 +76,7 @@ from requests_mock import Mocker - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/e2e/cmd_changelog/test_changelog_custom_parser.py b/tests/e2e/cmd_changelog/test_changelog_custom_parser.py index 72b430875..5aa199bcb 100644 --- a/tests/e2e/cmd_changelog/test_changelog_custom_parser.py +++ b/tests/e2e/cmd_changelog/test_changelog_custom_parser.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from pathlib import Path - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) diff --git a/tests/e2e/cmd_version/bump_version/conftest.py b/tests/e2e/cmd_version/bump_version/conftest.py index 3af0678df..04a5209ce 100644 --- a/tests/e2e/cmd_version/bump_version/conftest.py +++ b/tests/e2e/cmd_version/bump_version/conftest.py @@ -18,13 +18,22 @@ from tests.conftest import RunCliFn from tests.fixtures.example_project import UpdatePyprojectTomlFn - from tests.fixtures.git_repo import BuildRepoFromDefinitionFn, RepoActionConfigure + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + RepoActionConfigure, + RepoActionConfigureMonorepo, + RepoActionCreateMonorepo, + ) class InitMirrorRepo4RebuildFn(Protocol): def __call__( self, mirror_repo_dir: Path, - configuration_steps: Sequence[RepoActionConfigure], + configuration_steps: Sequence[ + RepoActionConfigure + | RepoActionCreateMonorepo + | RepoActionConfigureMonorepo + ], files_to_remove: Sequence[Path], ) -> Path: ... @@ -43,7 +52,9 @@ def init_mirror_repo_for_rebuild( ) -> InitMirrorRepo4RebuildFn: def _init_mirror_repo_for_rebuild( mirror_repo_dir: Path, - configuration_steps: Sequence[RepoActionConfigure], + configuration_steps: Sequence[ + RepoActionConfigure | RepoActionCreateMonorepo | RepoActionConfigureMonorepo + ], files_to_remove: Sequence[Path], ) -> Path: # Create the mirror repo directory diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py index ad720baf7..79d94b37b 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_1_channel( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_1_channel( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py index c87a3f2db..2b8c046e1 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_2_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_2_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_2_channels( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py index cadba665a..a26988646 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py @@ -59,6 +59,7 @@ def test_gitflow_repo_rebuild_3_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -105,6 +106,7 @@ def test_gitflow_repo_rebuild_3_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -162,5 +164,8 @@ def test_gitflow_repo_rebuild_3_channels( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py index 734aeceba..0adde1fa3 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_4_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_4_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_4_channels( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py index 093b80cb3..edeb485ce 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py @@ -57,6 +57,7 @@ def test_githubflow_repo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_githubflow_repo_rebuild_1_channel( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_githubflow_repo_rebuild_1_channel( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py index 36d156077..1d54aa459 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py @@ -63,6 +63,7 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -111,6 +112,7 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -174,5 +176,8 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py index 90b6ea16d..ab6906eea 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py @@ -57,6 +57,7 @@ def test_githubflow_repo_rebuild_2_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_githubflow_repo_rebuild_2_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_githubflow_repo_rebuild_2_channels( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/__init__.py b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py new file mode 100644 index 000000000..1d82138f2 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from freezegun import freeze_time + +from semantic_release.version.version import Version + +from tests.const import RepoActionStep +from tests.fixtures.monorepos.github_flow import ( + monorepo_w_github_flow_w_default_release_channel_conventional_commits, +) +from tests.util import temporary_working_directory + +if TYPE_CHECKING: + from typing import Literal, Sequence + from unittest.mock import MagicMock + + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) + from tests.e2e.conftest import GetSanitizedChangelogContentFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionConfigureMonorepo, + RepoActionCreateMonorepo, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + monorepo_w_github_flow_w_default_release_channel_conventional_commits.__name__, + ] + ], +) +def test_githubflow_monorepo_rebuild_1_channel( + repo_fixture_name: str, + run_psr_release: RunPSReleaseFn, + build_monorepo_w_github_flow_w_default_release_channel: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, + mocked_git_push: MagicMock, + post_mocker: Mocker, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, + monorepo_pkg1_pyproject_toml_file: Path, + monorepo_pkg2_pyproject_toml_file: Path, + monorepo_pkg1_version_py_file: Path, + monorepo_pkg2_version_py_file: Path, + monorepo_pkg1_changelog_md_file: Path, + monorepo_pkg2_changelog_md_file: Path, + monorepo_pkg1_changelog_rst_file: Path, + monorepo_pkg2_changelog_rst_file: Path, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type = cast( + "CommitConvention", repo_fixture_name.split("commits", 1)[0].split("_")[-2] + ) + target_repo_definition = build_monorepo_w_github_flow_w_default_release_channel( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # split repo actions by release actions + release_tags_2_steps = split_repo_actions_by_release_tags(target_repo_definition) + + configuration_steps = cast( + "Sequence[RepoActionConfigure | RepoActionCreateMonorepo | RepoActionConfigureMonorepo]", + release_tags_2_steps.pop(None), + ) + + release_versions_2_steps = cast( + "dict[Version | Literal['Unreleased'], list[RepoActions]]", + release_tags_2_steps, + ) + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_steps=configuration_steps, + files_to_remove=[], + ) + + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_key, steps in release_versions_2_steps.items(): + curr_release_str = ( + curr_release_key.as_tag() + if isinstance(curr_release_key, Version) + else curr_release_key + ) + + # make sure mocks are clear + mocked_git_fetch.reset_mock() + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + if curr_release_str != "Unreleased": + target_git_repo.git.checkout(curr_release_str, detach=True, force=True) + + expected_pkg1_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg1_changelog_md_file + ) + expected_pkg2_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg2_changelog_md_file + ) + expected_pkg1_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg1_changelog_rst_file + ) + expected_pkg2_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg2_changelog_rst_file + ) + expected_pkg1_pyproject_toml_content = ( + target_repo_dir / monorepo_pkg1_pyproject_toml_file + ).read_text() + expected_pkg2_pyproject_toml_content = ( + target_repo_dir / monorepo_pkg2_pyproject_toml_file + ).read_text() + expected_pkg1_version_file_content = ( + target_repo_dir / monorepo_pkg1_version_py_file + ).read_text() + expected_pkg2_version_file_content = ( + target_repo_dir / monorepo_pkg2_version_py_file + ).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + # stop before the release step + repo_construction_steps=steps[ + : -1 if curr_release_str != "Unreleased" else None + ], + ) + + release_directory = mirror_repo_dir + + for step in steps[::-1]: # reverse order + if step["action"] == RepoActionStep.CHANGE_DIRECTORY: + release_directory = ( + mirror_repo_dir + if str(Path(step["details"]["directory"])) + == str(mirror_repo_dir.root) + else Path(step["details"]["directory"]) + ) + + release_directory = ( + mirror_repo_dir / release_directory + if not release_directory.is_absolute() + else release_directory + ) + + if mirror_repo_dir not in release_directory.parents: + release_directory = mirror_repo_dir + + break + + # Act: run PSR on the repo instead of the RELEASE step + if curr_release_str != "Unreleased": + release_action_step = cast("RepoActionRelease", steps[-1]) + + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(release_directory): + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, + config_toml_path=Path("pyproject.toml"), + ) + else: + # run psr changelog command to validate changelog + pass + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pkg1_pyproject_toml_content = ( + mirror_repo_dir / monorepo_pkg1_pyproject_toml_file + ).read_text() + actual_pkg2_pyproject_toml_content = ( + mirror_repo_dir / monorepo_pkg2_pyproject_toml_file + ).read_text() + actual_pkg1_version_file_content = ( + mirror_repo_dir / monorepo_pkg1_version_py_file + ).read_text() + actual_pkg2_version_file_content = ( + mirror_repo_dir / monorepo_pkg2_version_py_file + ).read_text() + actual_pkg1_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg1_changelog_md_file + ) + actual_pkg2_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg2_changelog_md_file + ) + actual_pkg1_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg1_changelog_rst_file + ) + actual_pkg2_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg2_changelog_rst_file + ) + + # Evaluate (normal release actions should have occurred as expected) + # ------------------------------------------------------------------ + # Make sure version file is updated + assert ( + expected_pkg1_pyproject_toml_content == actual_pkg1_pyproject_toml_content + ) + assert ( + expected_pkg2_pyproject_toml_content == actual_pkg2_pyproject_toml_content + ) + assert expected_pkg1_version_file_content == actual_pkg1_version_file_content + assert expected_pkg2_version_file_content == actual_pkg2_version_file_content + + # Make sure changelog is updated + assert expected_pkg1_md_changelog_content == actual_pkg1_md_changelog_content + assert expected_pkg2_md_changelog_content == actual_pkg2_md_changelog_content + assert expected_pkg1_rst_changelog_content == actual_pkg1_rst_changelog_content + assert expected_pkg2_rst_changelog_content == actual_pkg2_rst_changelog_content + + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + + if curr_release_str != "Unreleased": + # Make sure tag is created + assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] + + # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py new file mode 100644 index 000000000..1352995b9 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from freezegun import freeze_time + +from semantic_release.version.version import Version + +from tests.const import RepoActionStep +from tests.fixtures.monorepos.github_flow import ( + monorepo_w_github_flow_w_feature_release_channel_conventional_commits, +) +from tests.util import temporary_working_directory + +if TYPE_CHECKING: + from typing import Literal, Sequence + from unittest.mock import MagicMock + + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) + from tests.e2e.conftest import GetSanitizedChangelogContentFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionConfigureMonorepo, + RepoActionCreateMonorepo, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + monorepo_w_github_flow_w_feature_release_channel_conventional_commits.__name__, + ] + ], +) +def test_githubflow_monorepo_rebuild_2_channels( + repo_fixture_name: str, + run_psr_release: RunPSReleaseFn, + build_monorepo_w_github_flow_w_feature_release_channel: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, + mocked_git_push: MagicMock, + post_mocker: Mocker, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, + monorepo_pkg1_pyproject_toml_file: Path, + monorepo_pkg2_pyproject_toml_file: Path, + monorepo_pkg1_version_py_file: Path, + monorepo_pkg2_version_py_file: Path, + monorepo_pkg1_changelog_md_file: Path, + monorepo_pkg2_changelog_md_file: Path, + monorepo_pkg1_changelog_rst_file: Path, + monorepo_pkg2_changelog_rst_file: Path, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type = cast( + "CommitConvention", repo_fixture_name.split("commits", 1)[0].split("_")[-2] + ) + target_repo_definition = build_monorepo_w_github_flow_w_feature_release_channel( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # split repo actions by release actions + release_tags_2_steps = split_repo_actions_by_release_tags(target_repo_definition) + + configuration_steps = cast( + "Sequence[RepoActionConfigure | RepoActionCreateMonorepo | RepoActionConfigureMonorepo]", + release_tags_2_steps.pop(None), + ) + + release_versions_2_steps = cast( + "dict[Version | Literal['Unreleased'], list[RepoActions]]", + release_tags_2_steps, + ) + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_steps=configuration_steps, + files_to_remove=[], + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_key, steps in release_versions_2_steps.items(): + curr_release_str = ( + curr_release_key.as_tag() + if isinstance(curr_release_key, Version) + else curr_release_key + ) + + # make sure mocks are clear + mocked_git_fetch.reset_mock() + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + if curr_release_str != "Unreleased": + target_git_repo.git.checkout(curr_release_str, detach=True, force=True) + + expected_pkg1_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg1_changelog_md_file + ) + expected_pkg2_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg2_changelog_md_file + ) + expected_pkg1_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg1_changelog_rst_file + ) + expected_pkg2_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg2_changelog_rst_file + ) + expected_pkg1_pyproject_toml_content = ( + target_repo_dir / monorepo_pkg1_pyproject_toml_file + ).read_text() + expected_pkg2_pyproject_toml_content = ( + target_repo_dir / monorepo_pkg2_pyproject_toml_file + ).read_text() + expected_pkg1_version_file_content = ( + target_repo_dir / monorepo_pkg1_version_py_file + ).read_text() + expected_pkg2_version_file_content = ( + target_repo_dir / monorepo_pkg2_version_py_file + ).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + # stop before the release step + repo_construction_steps=steps[ + : -1 if curr_release_str != "Unreleased" else None + ], + ) + + release_directory = mirror_repo_dir + + for step in steps[::-1]: # reverse order + if step["action"] == RepoActionStep.CHANGE_DIRECTORY: + release_directory = ( + mirror_repo_dir + if str(Path(step["details"]["directory"])) + == str(mirror_repo_dir.root) + else Path(step["details"]["directory"]) + ) + + release_directory = ( + mirror_repo_dir / release_directory + if not release_directory.is_absolute() + else release_directory + ) + + if mirror_repo_dir not in release_directory.parents: + release_directory = mirror_repo_dir + + break + + # Act: run PSR on the repo instead of the RELEASE step + if curr_release_str != "Unreleased": + release_action_step = cast("RepoActionRelease", steps[-1]) + + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(release_directory): + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, + config_toml_path=Path("pyproject.toml"), + ) + else: + # run psr changelog command to validate changelog + pass + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pkg1_pyproject_toml_content = ( + mirror_repo_dir / monorepo_pkg1_pyproject_toml_file + ).read_text() + actual_pkg2_pyproject_toml_content = ( + mirror_repo_dir / monorepo_pkg2_pyproject_toml_file + ).read_text() + actual_pkg1_version_file_content = ( + mirror_repo_dir / monorepo_pkg1_version_py_file + ).read_text() + actual_pkg2_version_file_content = ( + mirror_repo_dir / monorepo_pkg2_version_py_file + ).read_text() + actual_pkg1_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg1_changelog_md_file + ) + actual_pkg2_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg2_changelog_md_file + ) + actual_pkg1_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg1_changelog_rst_file + ) + actual_pkg2_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg2_changelog_rst_file + ) + + # Evaluate (normal release actions should have occurred as expected) + # ------------------------------------------------------------------ + # Make sure version file is updated + assert ( + expected_pkg1_pyproject_toml_content == actual_pkg1_pyproject_toml_content + ) + assert ( + expected_pkg2_pyproject_toml_content == actual_pkg2_pyproject_toml_content + ) + assert expected_pkg1_version_file_content == actual_pkg1_version_file_content + assert expected_pkg2_version_file_content == actual_pkg2_version_file_content + + # Make sure changelog is updated + assert expected_pkg1_md_changelog_content == actual_pkg1_md_changelog_content + assert expected_pkg2_md_changelog_content == actual_pkg2_md_changelog_content + assert expected_pkg1_rst_changelog_content == actual_pkg1_rst_changelog_content + assert expected_pkg2_rst_changelog_content == actual_pkg2_rst_changelog_content + + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + + if curr_release_str != "Unreleased": + # Make sure tag is created + assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] + + # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py index 8a68c20de..c57c6d58c 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py @@ -59,6 +59,7 @@ def test_trunk_repo_rebuild_only_official_releases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -105,6 +106,7 @@ def test_trunk_repo_rebuild_only_official_releases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -163,5 +165,8 @@ def test_trunk_repo_rebuild_only_official_releases( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py index 6236f5b55..6ea7e1f54 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py @@ -60,6 +60,7 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -106,6 +107,7 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -169,5 +171,8 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py index 020a4e6ac..89819abe9 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py @@ -61,6 +61,7 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -107,6 +108,7 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -170,5 +172,8 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py index 4473f56e9..78eca0bd6 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py @@ -57,6 +57,7 @@ def test_trunk_repo_rebuild_w_prereleases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_trunk_repo_rebuild_w_prereleases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_trunk_repo_rebuild_w_prereleases( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/__init__.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py new file mode 100644 index 000000000..c68a35f5d --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from freezegun import freeze_time + +from semantic_release.version.version import Version + +from tests.const import RepoActionStep +from tests.fixtures.monorepos.trunk_based_dev import ( + monorepo_w_trunk_only_releases_conventional_commits, +) +from tests.util import temporary_working_directory + +if TYPE_CHECKING: + from typing import Literal, Sequence + from unittest.mock import MagicMock + + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) + from tests.e2e.conftest import GetSanitizedChangelogContentFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionConfigureMonorepo, + RepoActionCreateMonorepo, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + monorepo_w_trunk_only_releases_conventional_commits.__name__, + ] + ], +) +def test_trunk_monorepo_rebuild_1_channel( + repo_fixture_name: str, + run_psr_release: RunPSReleaseFn, + build_trunk_only_monorepo_w_tags: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, + mocked_git_push: MagicMock, + post_mocker: Mocker, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, + monorepo_pkg1_pyproject_toml_file: Path, + monorepo_pkg2_pyproject_toml_file: Path, + monorepo_pkg1_version_py_file: Path, + monorepo_pkg2_version_py_file: Path, + monorepo_pkg1_changelog_md_file: Path, + monorepo_pkg2_changelog_md_file: Path, + monorepo_pkg1_changelog_rst_file: Path, + monorepo_pkg2_changelog_rst_file: Path, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type = cast( + "CommitConvention", repo_fixture_name.split("commits", 1)[0].split("_")[-2] + ) + target_repo_definition = build_trunk_only_monorepo_w_tags( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # split repo actions by release actions + release_tags_2_steps = split_repo_actions_by_release_tags(target_repo_definition) + + configuration_steps = cast( + "Sequence[RepoActionConfigure | RepoActionCreateMonorepo | RepoActionConfigureMonorepo]", + release_tags_2_steps.pop(None), + ) + + release_versions_2_steps = cast( + "dict[Version | Literal['Unreleased'], list[RepoActions]]", + release_tags_2_steps, + ) + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_steps=configuration_steps, + files_to_remove=[], + ) + + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_key, steps in release_versions_2_steps.items(): + curr_release_str = ( + curr_release_key.as_tag() + if isinstance(curr_release_key, Version) + else curr_release_key + ) + + # make sure mocks are clear + mocked_git_fetch.reset_mock() + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + if curr_release_str != "Unreleased": + target_git_repo.git.checkout(curr_release_str, detach=True, force=True) + + expected_pkg1_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg1_changelog_md_file + ) + expected_pkg2_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg2_changelog_md_file + ) + expected_pkg1_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg1_changelog_rst_file + ) + expected_pkg2_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir, changelog_file=monorepo_pkg2_changelog_rst_file + ) + expected_pkg1_pyproject_toml_content = ( + target_repo_dir / monorepo_pkg1_pyproject_toml_file + ).read_text() + expected_pkg2_pyproject_toml_content = ( + target_repo_dir / monorepo_pkg2_pyproject_toml_file + ).read_text() + expected_pkg1_version_file_content = ( + target_repo_dir / monorepo_pkg1_version_py_file + ).read_text() + expected_pkg2_version_file_content = ( + target_repo_dir / monorepo_pkg2_version_py_file + ).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + # stop before the release step + repo_construction_steps=steps[ + : -1 if curr_release_str != "Unreleased" else None + ], + ) + + release_directory = mirror_repo_dir + + for step in steps[::-1]: # reverse order + if step["action"] == RepoActionStep.CHANGE_DIRECTORY: + release_directory = ( + mirror_repo_dir + if str(Path(step["details"]["directory"])) + == str(mirror_repo_dir.root) + else Path(step["details"]["directory"]) + ) + + release_directory = ( + mirror_repo_dir / release_directory + if not release_directory.is_absolute() + else release_directory + ) + + if mirror_repo_dir not in release_directory.parents: + release_directory = mirror_repo_dir + + break + + # Act: run PSR on the repo instead of the RELEASE step + if curr_release_str != "Unreleased": + release_action_step = cast("RepoActionRelease", steps[-1]) + + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(release_directory): + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, + config_toml_path=Path("pyproject.toml"), + ) + else: + # run psr changelog command to validate changelog + pass + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pkg1_pyproject_toml_content = ( + mirror_repo_dir / monorepo_pkg1_pyproject_toml_file + ).read_text() + actual_pkg2_pyproject_toml_content = ( + mirror_repo_dir / monorepo_pkg2_pyproject_toml_file + ).read_text() + actual_pkg1_version_file_content = ( + mirror_repo_dir / monorepo_pkg1_version_py_file + ).read_text() + actual_pkg2_version_file_content = ( + mirror_repo_dir / monorepo_pkg2_version_py_file + ).read_text() + actual_pkg1_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg1_changelog_md_file + ) + actual_pkg2_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg2_changelog_md_file + ) + actual_pkg1_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg1_changelog_rst_file + ) + actual_pkg2_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir, changelog_file=monorepo_pkg2_changelog_rst_file + ) + + # Evaluate (normal release actions should have occurred as expected) + # ------------------------------------------------------------------ + # Make sure version file is updated + assert ( + expected_pkg1_pyproject_toml_content == actual_pkg1_pyproject_toml_content + ) + assert ( + expected_pkg2_pyproject_toml_content == actual_pkg2_pyproject_toml_content + ) + assert expected_pkg1_version_file_content == actual_pkg1_version_file_content + assert expected_pkg2_version_file_content == actual_pkg2_version_file_content + + # Make sure changelog is updated + assert expected_pkg1_md_changelog_content == actual_pkg1_md_changelog_content + assert expected_pkg2_md_changelog_content == actual_pkg2_md_changelog_content + assert expected_pkg1_rst_changelog_content == actual_pkg1_rst_changelog_content + assert expected_pkg2_rst_changelog_content == actual_pkg2_rst_changelog_content + + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + + if curr_release_str != "Unreleased": + # Make sure tag is created + assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] + + # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/test_version.py b/tests/e2e/cmd_version/test_version.py index 0af19f8a3..2bf952647 100644 --- a/tests/e2e/cmd_version/test_version.py +++ b/tests/e2e/cmd_version/test_version.py @@ -42,6 +42,7 @@ def test_version_noop_is_noop( repo_result: BuiltRepoResult, next_release_version: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_wheel_file: GetWheelFileFn, @@ -91,6 +92,7 @@ def test_version_no_git_verify( repo_result: BuiltRepoResult, run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -101,6 +103,11 @@ def test_version_no_git_verify( repo.git.commit( m="chore: adjust project configuration for --no-verify release commits", a=True ) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # setup: create executable pre-commit script precommit_hook = Path(repo.git_dir, "hooks", "pre-commit") @@ -140,6 +147,7 @@ def test_version_no_git_verify( # A commit has been made (regardless of precommit) assert [head_sha_before] == [head.hexsha for head in head_after.parents] assert len(tags_set_difference) == 1 # A tag has been created + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -150,6 +158,7 @@ def test_version_no_git_verify( def test_version_on_nonrelease_branch( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -196,6 +205,7 @@ def test_version_on_last_release( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -248,6 +258,7 @@ def test_version_on_last_release( def test_version_only_tag_push( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ) -> None: diff --git a/tests/e2e/cmd_version/test_version_build.py b/tests/e2e/cmd_version/test_version_build.py index c9380683c..51022da87 100644 --- a/tests/e2e/cmd_version/test_version_build.py +++ b/tests/e2e/cmd_version/test_version_build.py @@ -68,6 +68,7 @@ def test_version_runs_build_command( shell: str, get_wheel_file: GetWheelFileFn, example_pyproject_toml: Path, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, ): @@ -125,6 +126,9 @@ def test_version_runs_build_command( ) assert built_wheel_file.exists() + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 @@ -150,6 +154,7 @@ def test_version_runs_build_command_windows( get_wheel_file: GetWheelFileFn, example_pyproject_toml: Path, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, clean_os_environment: dict[str, str], @@ -223,6 +228,9 @@ def test_version_runs_build_command_windows( dist_file_exists = built_wheel_file.exists() assert dist_file_exists, f"\n Expected wheel file to be created at {built_wheel_file}, but it does not exist." + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 @@ -338,6 +346,7 @@ def test_version_runs_build_command_w_user_env( @pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) def test_version_skips_build_command_with_skip_build( run_cli: RunCliFn, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, ): @@ -354,5 +363,6 @@ def test_version_skips_build_command_with_skip_build( assert_successful_exit_code(result, cli_cmd) patched_subprocess_run.assert_not_called() + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 diff --git a/tests/e2e/cmd_version/test_version_bump.py b/tests/e2e/cmd_version/test_version_bump.py index 5feade30c..5739b8f3b 100644 --- a/tests/e2e/cmd_version/test_version_bump.py +++ b/tests/e2e/cmd_version/test_version_bump.py @@ -321,6 +321,7 @@ def test_version_force_level( example_project_dir: ExProjectDir, example_pyproject_toml: Path, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, pyproject_toml_file: Path, @@ -390,6 +391,7 @@ def test_version_force_level( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -534,6 +536,7 @@ def test_version_next_greater_than_version_one_conventional( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -553,6 +556,11 @@ def test_version_next_greater_than_version_one_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -587,6 +595,7 @@ def test_version_next_greater_than_version_one_conventional( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -673,6 +682,7 @@ def test_version_next_greater_than_version_one_no_bump_conventional( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -692,6 +702,11 @@ def test_version_next_greater_than_version_one_no_bump_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -724,6 +739,7 @@ def test_version_next_greater_than_version_one_no_bump_conventional( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -833,6 +849,7 @@ def test_version_next_greater_than_version_one_emoji( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -852,6 +869,11 @@ def test_version_next_greater_than_version_one_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -886,6 +908,7 @@ def test_version_next_greater_than_version_one_emoji( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -972,6 +995,7 @@ def test_version_next_greater_than_version_one_no_bump_emoji( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -991,6 +1015,11 @@ def test_version_next_greater_than_version_one_no_bump_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1023,6 +1052,7 @@ def test_version_next_greater_than_version_one_no_bump_emoji( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -1132,6 +1162,7 @@ def test_version_next_greater_than_version_one_scipy( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1151,6 +1182,11 @@ def test_version_next_greater_than_version_one_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1185,6 +1221,7 @@ def test_version_next_greater_than_version_one_scipy( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -1271,6 +1308,7 @@ def test_version_next_greater_than_version_one_no_bump_scipy( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1290,6 +1328,11 @@ def test_version_next_greater_than_version_one_no_bump_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1322,6 +1365,7 @@ def test_version_next_greater_than_version_one_no_bump_scipy( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -1612,6 +1656,7 @@ def test_version_next_w_zero_dot_versions_conventional( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1637,6 +1682,11 @@ def test_version_next_w_zero_dot_versions_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1671,6 +1721,7 @@ def test_version_next_w_zero_dot_versions_conventional( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -1766,6 +1817,7 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1791,6 +1843,11 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1823,6 +1880,7 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -2092,6 +2150,7 @@ def test_version_next_w_zero_dot_versions_emoji( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2117,6 +2176,11 @@ def test_version_next_w_zero_dot_versions_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2151,6 +2215,7 @@ def test_version_next_w_zero_dot_versions_emoji( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -2246,6 +2311,7 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2271,6 +2337,11 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2303,6 +2374,7 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -2572,6 +2644,7 @@ def test_version_next_w_zero_dot_versions_scipy( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2597,6 +2670,11 @@ def test_version_next_w_zero_dot_versions_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2631,6 +2709,7 @@ def test_version_next_w_zero_dot_versions_scipy( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -2726,6 +2805,7 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2751,6 +2831,11 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2783,6 +2868,7 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -3115,6 +3201,7 @@ def test_version_next_w_zero_dot_versions_minimums( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -3141,6 +3228,11 @@ def test_version_next_w_zero_dot_versions_minimums( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -3175,5 +3267,6 @@ def test_version_next_w_zero_dot_versions_minimums( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/test_version_partial_tag.py b/tests/e2e/cmd_version/test_version_partial_tag.py new file mode 100644 index 000000000..b6d2024d4 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_partial_tag.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from tests.const import EXAMPLE_PROJECT_NAME, MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures import ( + repo_w_no_tags_conventional_commits, +) +from tests.util import ( + assert_successful_exit_code, + dynamic_python_import, +) + +if TYPE_CHECKING: + from typing import List + from unittest.mock import MagicMock + + from requests_mock import Mocker + from typing_extensions import TypeAlias + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ( + ExProjectDir, + GetExpectedVersionPyFileContentFn, + UpdatePyprojectTomlFn, + ) + from tests.fixtures.git_repo import BuiltRepoResult + + CaseId: TypeAlias = str + CliArgs: TypeAlias = List[str] + NextReleaseVersion: TypeAlias = str + ExistingTags: TypeAlias = List[str] + ExpectedNewPartialTags: TypeAlias = List[str] + ExpectedMovedPartialTags: TypeAlias = List[str] + + +cases: tuple[ + tuple[ + CaseId, + CliArgs, + NextReleaseVersion, + ExistingTags, + ExpectedNewPartialTags, + ExpectedMovedPartialTags, + ], + ..., +] = ( + # pre-release should not affect partial tags + ( + "pre-release", + ["--prerelease"], + "0.0.0-rc.1", + ["v0", "v0.0"], + [], + [], + ), + # Create partial tags when they don't exist + ( + "create-partial-tags-when-they-dont-exist__build-metadata", + ["--minor", "--build-metadata", "build.12345"], + "0.1.0+build.12345", + [], + ["v0", "v0.1", "v0.1.0"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__patch", + ["--patch"], + "0.0.1", + [], + ["v0", "v0.0"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__minor", + ["--minor"], + "0.1.0", + [], + ["v0", "v0.1"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__major", + ["--major"], + "1.0.0", + [], + ["v1", "v1.0"], + [], + ), + # Update existing partial tags + ( + "update-existing-partial-tags__build-metadata", + ["--patch", "--build-metadata", "build.12345"], + "0.1.1+build.12345", + ["v0", "v0.0", "v0.1", "v0.1.0"], + ["v0.1.1"], + ["v0", "v0.1"], + ), + ( + "update-existing-partial-tags__patch", + ["--patch"], + "0.0.1", + ["v0", "v0.0"], + [], + ["v0", "v0.0"], + ), + ( + "update-existing-partial-tags__minor", + ["--minor"], + "0.1.0", + ["v0", "v0.0", "v0.1"], + [], + ["v0", "v0.1"], + ), + ( + "update-existing-partial-tags__major", + ["--major"], + "1.0.0", + ["v0", "v0.0", "v0.1", "v1", "v1.0"], + [], + ["v1", "v1.0"], + ), + # Update existing partial tags and create new one + ( + "update-existing-partial-tags-and-create-new-one", + ["--minor"], + "0.1.0", + ["v0", "v0.0"], + ["v0.1"], + ["v0"], + ), + # Partial tag disabled for older version, now enabled + ( + "partial-tag-disabled-for-older-version__build-metadata", + ["--patch", "--build-metadata", "build.12345"], + "1.1.2+build.12345", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1+build.1234"], + ["v1", "v1.1", "v1.1.2"], + [], + ), + ( + "partial-tag-disabled-for-older-version__patch", + ["--patch"], + "1.1.2", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v1", "v1.1"], + [], + ), + ( + "partial-tag-disabled-for-older-version__minor", + ["--minor"], + "1.2.0", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v1", "v1.2"], + [], + ), + ( + "partial-tag-enabled-for-newer-version__major", + ["--major"], + "2.0.0", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v2", "v2.0"], + [], + ), +) + + +@pytest.mark.parametrize( + "repo_result, add_partial_tags, cli_args, next_release_version, existing_partial_tags, expected_new_partial_tags, expected_moved_partial_tags", + [ + *( + pytest.param( + lazy_fixture(repo_w_no_tags_conventional_commits.__name__), + True, + cli_args, + next_release_version, + existing_tags, + expected_new_partial_tags, + expected_moved_partial_tags, + id=f"{case_id}__partial-tags-enabled", + ) + for case_id, cli_args, next_release_version, existing_tags, expected_new_partial_tags, expected_moved_partial_tags in cases + ), + *( + pytest.param( + lazy_fixture(repo_w_no_tags_conventional_commits.__name__), + False, + cli_args, + next_release_version, + existing_tags, + expected_new_partial_tags, + expected_moved_partial_tags, + id=f"{case_id}__partial-tags-disabled", + ) + for case_id, cli_args, next_release_version, existing_tags, expected_new_partial_tags, expected_moved_partial_tags in cases + ), + ], +) +def test_version_partial_tag_creation( + repo_result: BuiltRepoResult, + add_partial_tags: bool, + cli_args: list[str], + next_release_version: str, + example_project_dir: ExProjectDir, + example_pyproject_toml: Path, + existing_partial_tags: list[str], + expected_new_partial_tags: list[str], + expected_moved_partial_tags: list[str], + run_cli: RunCliFn, + mocked_git_fetch: MagicMock, + mocked_git_push: MagicMock, + post_mocker: Mocker, + update_pyproject_toml: UpdatePyprojectTomlFn, + pyproject_toml_file: Path, + changelog_md_file: Path, + version_py_file: Path, + get_expected_version_py_file_content: GetExpectedVersionPyFileContentFn, +): + # Force clean directory state before test (needed for the repo_w_no_tags) + repo = repo_result["repo"] + repo.git.reset("HEAD", hard=True) + + # Enable partial tags + update_pyproject_toml("tool.semantic_release.add_partial_tags", add_partial_tags) + + expected_changed_files = sorted( + [ + str(changelog_md_file), + str(pyproject_toml_file), + str(version_py_file), + ] + ) + expected_new_partial_tags = expected_new_partial_tags if add_partial_tags else [] + expected_moved_partial_tags = ( + expected_moved_partial_tags if add_partial_tags else [] + ) + + expected_version_py_content = get_expected_version_py_file_content( + next_release_version + ) + + # Setup: create existing tags + for tag in existing_partial_tags: + repo.create_tag(tag) + + # Setup: take measurement before running the version command + head_sha_before = repo.head.commit.hexsha + tags_before = {tag.name: repo.commit(tag) for tag in repo.tags} + + pyproject_toml_before = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ) + + # Modify the pyproject.toml to remove the version so we can compare it later + pyproject_toml_before.get("tool", {}).get("poetry", {}).pop("version", None) + + # Define expectations before execution (hypothesis) + expected_git_fetch_calls = 1 + expected_vcs_release_calls = 1 + # 1 for commit, 1 for tag, 1 for each moved or created partial tag + expected_git_push_calls = ( + 2 + len(expected_new_partial_tags) + len(expected_moved_partial_tags) + ) + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *cli_args] + result = run_cli(cli_cmd[1:]) + + # take measurement after running the version command + head_after = repo.head.commit + tags_after = {tag.name: repo.commit(tag.name) for tag in repo.tags} + new_tags = {tag: sha for tag, sha in tags_after.items() if tag not in tags_before} + moved_tags = { + tag: sha + for tag, sha in tags_after.items() + if tag in tags_before and sha != tags_before[tag] + } + differing_files = sorted( + [ + # Make sure filepath uses os specific path separators + str(Path(file)) + for file in str( + repo.git.diff("HEAD", "HEAD~1", name_only=True) + ).splitlines() + ] + ) + pyproject_toml_after = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ) + pyproj_version_after = ( + pyproject_toml_after.get("tool", {}).get("poetry", {}).pop("version") + ) + + # Load python module for reading the version (ensures the file is valid) + actual_version_py_content = (example_project_dir / version_py_file).read_text() + + # Evaluate (normal release actions should have occurred when forced patch bump) + assert_successful_exit_code(result, cli_cmd) + + # A commit has been made + assert [head_sha_before] == [head.hexsha for head in head_after.parents] + + # A version tag and the expected partial tag have been created + assert 1 + len(expected_new_partial_tags) == len(new_tags) + assert len(expected_moved_partial_tags) == len(moved_tags) + assert f"v{next_release_version}" in new_tags + + # Check that all new tags and moved tags are present and on the head commit + for partial_tag in expected_new_partial_tags: + assert partial_tag in new_tags + assert repo.commit(partial_tag).hexsha == head_after.hexsha + + for partial_tag in expected_moved_partial_tags: + assert partial_tag in moved_tags + assert repo.commit(partial_tag).hexsha == head_after.hexsha + + # Expected external calls + assert ( + expected_git_fetch_calls == mocked_git_fetch.call_count + ) # fetch occurred before push + assert expected_git_push_calls == mocked_git_push.call_count + assert ( + expected_vcs_release_calls == post_mocker.call_count + ) # vcs release creation occurred + + # Changelog already reflects changes this should introduce + assert expected_changed_files == differing_files + + # Compare pyproject.toml + assert pyproject_toml_before == pyproject_toml_after + assert next_release_version == pyproj_version_after + + # Compare _version.py + assert expected_version_py_content == actual_version_py_content + + # Verify content is parsable & importable + dynamic_version = dynamic_python_import( + example_project_dir / version_py_file, f"{EXAMPLE_PROJECT_NAME}._version" + ).__version__ + + assert next_release_version == dynamic_version diff --git a/tests/e2e/cmd_version/test_version_print.py b/tests/e2e/cmd_version/test_version_print.py index 9bcc2dea8..cd4ed2406 100644 --- a/tests/e2e/cmd_version/test_version_print.py +++ b/tests/e2e/cmd_version/test_version_print.py @@ -549,7 +549,6 @@ def test_version_print_last_released_prints_nothing_if_no_tags( run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, - caplog: pytest.LogCaptureFixture, ): repo = repo_result["repo"] @@ -571,10 +570,7 @@ def test_version_print_last_released_prints_nothing_if_no_tags( # Evaluate (no release actions should have occurred on print) assert_successful_exit_code(result, cli_cmd) assert result.stdout == "" - - # must use capture log to see this, because we use the logger to print this message - # not click's output - assert "No release tags found." in caplog.text + assert "No release tags found." in result.stderr # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) assert repo_status_before == repo_status_after @@ -809,7 +805,6 @@ def test_version_print_last_released_tag_prints_nothing_if_no_tags( run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, - caplog: pytest.LogCaptureFixture, ): repo = repo_result["repo"] @@ -831,10 +826,7 @@ def test_version_print_last_released_tag_prints_nothing_if_no_tags( # Evaluate (no release actions should have occurred on print) assert_successful_exit_code(result, cli_cmd) assert result.stdout == "" - - # must use capture log to see this, because we use the logger to print this message - # not click's output - assert "No release tags found." in caplog.text + assert "No release tags found." in result.stderr # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) assert repo_status_before == repo_status_after diff --git a/tests/e2e/cmd_version/test_version_release_notes.py b/tests/e2e/cmd_version/test_version_release_notes.py index e21059335..08032332d 100644 --- a/tests/e2e/cmd_version/test_version_release_notes.py +++ b/tests/e2e/cmd_version/test_version_release_notes.py @@ -55,6 +55,7 @@ def test_custom_release_notes_template( run_cli: RunCliFn, use_release_notes_template: UseReleaseNotesTemplateFn, retrieve_runtime_context: RetrieveRuntimeContextFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ) -> None: @@ -89,6 +90,7 @@ def test_custom_release_notes_template( # Assert assert_successful_exit_code(result, cli_cmd) + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 assert post_mocker.last_request is not None @@ -128,6 +130,7 @@ def test_default_release_notes_license_statement( license_setting: str, mask_initial_release: bool, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -175,6 +178,7 @@ def test_default_release_notes_license_statement( # Evaluate assert_successful_exit_code(result, cli_cmd) + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 assert post_mocker.last_request is not None diff --git a/tests/e2e/cmd_version/test_version_shallow.py b/tests/e2e/cmd_version/test_version_shallow.py new file mode 100644 index 000000000..fa088e548 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_shallow.py @@ -0,0 +1,306 @@ +"""Tests for version command with shallow repositories.""" + +from __future__ import annotations + +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from git import Repo +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.hvcs.github import Github + +from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures.example_project import change_to_ex_proj_dir +from tests.fixtures.repos import repo_w_trunk_only_conventional_commits +from tests.fixtures.repos.trunk_based_dev.repo_w_tags import ( + build_trunk_only_repo_w_tags, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from requests_mock import Mocker + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn + from tests.fixtures.git_repo import ( + BuildSpecificRepoFn, + CommitConvention, + GetCfgValueFromDefFn, + GetGitRepo4DirFn, + GetVersionsFromRepoBuildDefFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_w_shallow_repo_unshallows( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """ + Test that the version command automatically unshallows a shallow repository. + + Given a shallow repository, + When running the version command, + Then the repository should be unshallowed and release should succeed + """ + remote_name = "origin" + + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Create a shallow clone from the remote using file:// protocol for depth support + shallow_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "shallow_clone"), + no_local=True, + depth=1, + ) + with shallow_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + with shallow_repo: + # Verify it's a shallow clone + shallow_file = Path(shallow_repo.git_dir, "shallow") + assert shallow_file.exists(), "Repository should be shallow" + + # Capture expected values from the full repo + expected_vcs_url_post = 1 + commit_sha_before = shallow_repo.head.commit.hexsha + + # Run PSR on the shallow clone + with temporary_working_directory(str(shallow_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--patch"] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + # Initial execution check + assert_successful_exit_code(result, cli_cmd) + + # Take measurements after running PSR + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + with shallow_repo: + parent_commit_shas = [ + parent.hexsha for parent in shallow_repo.head.commit.parents + ] + commit_sha_after = shallow_repo.head.commit.hexsha + + # Verify the shallow file is gone (repo was unshallowed) + assert not shallow_file.exists(), "Repository should be unshallowed" + + # Verify release was successful + assert commit_sha_before != commit_sha_after, "Expected commit SHA to change" + assert ( + commit_sha_before in parent_commit_shas + ), "Expected new commit to be created on HEAD" + assert ( + latest_tag in different_tags + ), "Expected a new tag to be created and pushed to remote" + assert expected_vcs_url_post == post_mocker.call_count # 1x vcs release created + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_noop_w_shallow_repo( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """ + Test that the version command in noop mode reports unshallow action. + + Given a shallow repository, + When running the version command with --noop, + Then the command should report what it would do but not actually unshallow + """ + remote_name = "origin" + + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # Create a shallow clone from the remote using file:// protocol for depth support + shallow_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "shallow_clone"), + no_local=True, + depth=1, + ) + with shallow_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + with shallow_repo: + # Verify it's a shallow clone + shallow_file = Path(shallow_repo.git_dir, "shallow") + assert shallow_file.exists(), "Repository should be shallow" + + # Capture expected values from the full repo + expected_vcs_url_post = 0 + commit_sha_before = shallow_repo.head.commit.hexsha + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Run PSR in noop mode on the shallow clone + with temporary_working_directory(str(shallow_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--noop", VERSION_SUBCMD, "--patch"] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + # Initial execution check + assert_successful_exit_code(result, cli_cmd) + + # Take measurements after running PSR + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + with shallow_repo: + commit_sha_after = shallow_repo.head.commit.hexsha + + # Verify the shallow file still exists (repo was NOT actually unshallowed in noop) + assert shallow_file.exists(), "Repository should still be shallow in noop mode" + + # Verify no actual changes were made + assert ( + commit_sha_before == commit_sha_after + ), "Expected commit SHA to remain unchanged in noop mode" + assert not different_tags, "Expected no new tags to be created in noop mode" + assert expected_vcs_url_post == post_mocker.call_count diff --git a/tests/e2e/cmd_version/test_version_stamp.py b/tests/e2e/cmd_version/test_version_stamp.py index 892ce59d7..2bce55901 100644 --- a/tests/e2e/cmd_version/test_version_stamp.py +++ b/tests/e2e/cmd_version/test_version_stamp.py @@ -57,6 +57,7 @@ def test_version_only_stamp_version( repo_result: BuiltRepoResult, expected_new_version: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: MagicMock, example_pyproject_toml: Path, diff --git a/tests/e2e/cmd_version/test_version_strict.py b/tests/e2e/cmd_version/test_version_strict.py index a41998ded..e90970bd3 100644 --- a/tests/e2e/cmd_version/test_version_strict.py +++ b/tests/e2e/cmd_version/test_version_strict.py @@ -29,6 +29,7 @@ def test_version_already_released_when_strict( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -78,6 +79,7 @@ def test_version_already_released_when_strict( def test_version_on_nonrelease_branch_when_strict( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, diff --git a/tests/e2e/cmd_version/test_version_upstream_check.py b/tests/e2e/cmd_version/test_version_upstream_check.py new file mode 100644 index 000000000..a2b29425a --- /dev/null +++ b/tests/e2e/cmd_version/test_version_upstream_check.py @@ -0,0 +1,574 @@ +"""E2E tests for upstream verification during version command.""" + +from __future__ import annotations + +import contextlib +from pathlib import PureWindowsPath +from typing import TYPE_CHECKING, cast + +import pytest +from git import Repo +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.hvcs.github import Github + +from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures.example_project import change_to_ex_proj_dir +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_conventional_commits, +) +from tests.fixtures.repos.trunk_based_dev.repo_w_tags import ( + build_trunk_only_repo_w_tags, +) +from tests.util import ( + add_text_to_file, + assert_exit_code, + assert_successful_exit_code, + temporary_working_directory, +) + +if TYPE_CHECKING: + from pathlib import Path + + from requests_mock import Mocker + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn + from tests.fixtures.git_repo import ( + BuildSpecificRepoFn, + CommitConvention, + GetCfgValueFromDefFn, + GetGitRepo4DirFn, + GetVersionsFromRepoBuildDefFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_upstream_check_success_no_changes( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + remote_name = "origin" + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with contextlib.suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Clone the repo to simulate a local workspace + test_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "repo_clone"), + no_local=True, + ) + with test_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + current_head_sha = test_repo.head.commit.hexsha + + # Act: run PSR on the cloned repo - it should verify upstream and succeed + with temporary_working_directory(str(test_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Verify release occurred as expected + with test_repo: + assert latest_tag in test_repo.tags, "Expected release tag to be created" + assert current_head_sha in [ + parent.hexsha for parent in test_repo.head.commit.parents + ], "Expected new commit to be created on HEAD" + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + assert latest_tag in different_tags, "Expected new tag to be pushed to remote" + + # Verify VCS release was created + expected_vcs_url_post = 1 + assert expected_vcs_url_post == post_mocker.call_count # one vcs release created + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_upstream_check_success_no_changes_untracked_branch( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + """Test that PSR succeeds when the upstream branch is untracked but unchanged.""" + remote_name = "origin" + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with contextlib.suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # Simulate CI environment after someone pushes to the repo + ci_commit_sha = target_git_repo.head.commit.hexsha + ci_branch = target_git_repo.active_branch.name + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Simulate a CI environment by fetching the repo to a new location + test_repo = Repo.init(str(example_project_dir / "ci_repo")) + with test_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + # Configure and retrieve the repository (see GitHub actions/checkout@v5) + test_repo.git.remote( + "add", + remote_name, + f"file:///{PureWindowsPath(local_origin.working_dir).as_posix()}", + ) + test_repo.git.fetch("--depth=1", remote_name, ci_commit_sha) + + # Simulate CI environment and recommended workflow (in docs) + # NOTE: this could be done in 1 step, but most CI pipelines are doing it in 2 steps + # 1. Checkout the commit sha (detached head) + test_repo.git.checkout(ci_commit_sha, force=True) + # 2. Forcefully set the branch to the current detached head + test_repo.git.checkout("-B", ci_branch) + + # Act: run PSR on the cloned repo - it should verify upstream and succeed + with temporary_working_directory(str(test_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Verify release occurred as expected + with test_repo: + assert latest_tag in test_repo.tags, "Expected release tag to be created" + assert ci_commit_sha in [ + parent.hexsha for parent in test_repo.head.commit.parents + ], "Expected new commit to be created on HEAD" + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + assert latest_tag in different_tags, "Expected new tag to be pushed to remote" + + # Verify VCS release was created + expected_vcs_url_post = 1 + assert expected_vcs_url_post == post_mocker.call_count # one vcs release created + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_no_upstream_check_on_no_version_commit( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + """ + Test that PSR succeeds when no version commit is needed, so the upstream check is skipped. + + This replicates the scenario that occurred on python-semantic-release/publish-action@v10.5.1 + where the version command was run and no version commit was needed, but it failed because + it attempted to check the upstream branch anyway and we hard coded HEAD~1 because it expects + a version commit to be created. This is the only reason why you would check the upstream branch + because pushing a tag to the remote can happen even if the upstream branch has changed. + """ + remote_name = "origin" + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with contextlib.suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # Remove any version variables to ensure no version commit is needed + update_pyproject_toml( + "tool.semantic_release.version_variables", + None, + target_repo_dir / pyproject_toml_file, + ) + update_pyproject_toml( + "tool.semantic_release.version_toml", + None, + target_repo_dir / pyproject_toml_file, + ) + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # Simulate CI environment after someone pushes to the repo + ci_commit_sha = target_git_repo.head.commit.hexsha + ci_branch = target_git_repo.active_branch.name + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Simulate a CI environment by fetching the repo to a new location + test_repo = Repo.init(str(example_project_dir / "ci_repo")) + with test_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + # Configure and retrieve the repository (see GitHub actions/checkout@v5) + test_repo.git.remote( + "add", + remote_name, + f"file:///{PureWindowsPath(local_origin.working_dir).as_posix()}", + ) + test_repo.git.fetch("--depth=1", remote_name, ci_commit_sha) + + # Simulate CI environment and recommended workflow (in docs) + # NOTE: this could be done in 1 step, but most CI pipelines are doing it in 2 steps + # 1. Checkout the commit sha (detached head) + test_repo.git.checkout(ci_commit_sha, force=True) + # 2. Forcefully set the branch to the current detached head + test_repo.git.checkout("-B", ci_branch) + + # Act: run PSR on the cloned repo - it should verify upstream and succeed + with temporary_working_directory(str(test_repo.working_dir)): + # We don't use `--no-commit` here because we want to test that the upstream check is skipped + # when PSR determines that no version commit is needed. If we used `--no-commit`, it would skip the + # upstream check because it would think that a version commit was not needed. + cli_cmd = [ + MAIN_PROG_NAME, + "--strict", + VERSION_SUBCMD, + "--no-changelog", + "--skip-build", + ] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Verify release occurred as expected + with test_repo: + assert latest_tag in test_repo.tags, "Expected release tag to be created" + assert ( + ci_commit_sha == test_repo.head.commit.hexsha + ), "Expected no new commit to be created on HEAD" + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + assert latest_tag in different_tags, "Expected new tag to be pushed to remote" + + # Verify VCS release was created + expected_vcs_url_post = 1 + assert expected_vcs_url_post == post_mocker.call_count # one vcs release created + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_upstream_check_fails_when_upstream_changed( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, + file_in_repo: str, +): + remote_name = "origin" + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with contextlib.suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, f"file://{local_origin.working_dir}") + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the branch name used in the pushed repo + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Clone the repo to simulate a local workspace + test_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "repo_clone"), + no_local=True, + ) + with test_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + # Apply new commit to the original repo to simulate another developer pushing to upstream + add_text_to_file(target_git_repo, str(target_repo_dir / file_in_repo)) + target_git_repo.index.add([str(file_in_repo)]) + target_git_repo.index.commit("feat: upstream change by another developer") + target_git_repo.git.push(remote_name, target_git_repo.active_branch.name) + + # Act: run PSR - it should detect upstream changed and fail + with temporary_working_directory(str(test_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + + # Evaluate + assert_exit_code(1, result, cli_cmd) + expected_err_msg = ( + f"Upstream branch '{remote_name}/{test_repo.active_branch.name}' has changed!" + ) + # Verify error message mentions upstream + assert ( + expected_err_msg in result.stderr + ), f"Expected '{expected_err_msg}' in error output, got: {result.stderr}" + + assert ( + remote_origin_tags_before == remote_origin_tags_after + ), "Expected no new tags to be pushed to remote" + + # Verify no VCS release was created + expected_vcs_url_post = 0 + assert expected_vcs_url_post == post_mocker.call_count # no vcs release created diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 2cfcd67ea..328718fa0 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from unittest.mock import MagicMock +import git.remote as git_remote import pytest from requests_mock import ANY @@ -77,6 +78,26 @@ def mocked_git_push(monkeypatch: MonkeyPatch) -> MagicMock: return mocked_push +@pytest.fixture +def mocked_git_fetch(monkeypatch: MonkeyPatch) -> MagicMock: + """ + Mock the `Repo.git.fetch()` method in `semantic_release.cli.main` and + `git.Repo.remotes.Remote.fetch()`. + """ + mocked_fetch = MagicMock() + cls = prepare_mocked_git_command_wrapper_type(fetch=mocked_fetch) + monkeypatch.setattr(cli_config_module.Repo, "GitCommandWrapperType", cls) + + # define a small wrapper so the MagicMock does not receive `self` + def _fetch(self, *args, **kwargs): + return mocked_fetch(*args, **kwargs) + + # Replace the method on the Remote class used by GitPython + monkeypatch.setattr(git_remote.Remote, "fetch", _fetch, raising=True) + + return mocked_fetch + + @pytest.fixture def config_path(example_project_dir: ExProjectDir) -> Path: return example_project_dir / DEFAULT_CONFIG_FILE diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index d9e987f57..fcf471c9b 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -1,5 +1,6 @@ from tests.fixtures.commit_parsers import * from tests.fixtures.example_project import * from tests.fixtures.git_repo import * +from tests.fixtures.monorepos import * from tests.fixtures.repos import * from tests.fixtures.scipy import * diff --git a/tests/fixtures/example_project.py b/tests/fixtures/example_project.py index 6b804fdae..41664f75a 100644 --- a/tests/fixtures/example_project.py +++ b/tests/fixtures/example_project.py @@ -24,6 +24,9 @@ EmojiCommitParser, ScipyCommitParser, ) +from semantic_release.commit_parser.conventional.parser_monorepo import ( + ConventionalCommitMonorepoParser, +) from semantic_release.hvcs import Bitbucket, Gitea, Github, Gitlab import tests.conftest @@ -83,7 +86,7 @@ def __call__( class UseParserFn(Protocol): def __call__( - self, toml_file: Path | str = ... + self, toml_file: Path | str = ..., monorepo: bool = ... ) -> type[CommitParser[ParseResult, ParserOptions]]: ... class UseReleaseNotesTemplateFn(Protocol): @@ -606,13 +609,16 @@ def use_conventional_parser( """Modify the configuration file to use the Conventional parser.""" def _use_conventional_parser( - toml_file: Path | str = pyproject_toml_file, + toml_file: Path | str = pyproject_toml_file, monorepo: bool = False ) -> type[CommitParser[ParseResult, ParserOptions]]: update_pyproject_toml( - pyproject_toml_config_option_parser, "conventional", toml_file=toml_file + pyproject_toml_config_option_parser, + f"conventional{'-monorepo' if monorepo else ''}", + toml_file=toml_file, ) return cast( - "type[CommitParser[ParseResult, ParserOptions]]", ConventionalCommitParser + "type[CommitParser[ParseResult, ParserOptions]]", + ConventionalCommitMonorepoParser if monorepo else ConventionalCommitParser, ) return _use_conventional_parser @@ -627,8 +633,14 @@ def use_emoji_parser( """Modify the configuration file to use the Emoji parser.""" def _use_emoji_parser( - toml_file: Path | str = pyproject_toml_file, + toml_file: Path | str = pyproject_toml_file, monorepo: bool = False ) -> type[CommitParser[ParseResult, ParserOptions]]: + if monorepo: + raise ValueError( + "The Emoji parser does not support monorepo mode. " + "Use the conventional parser instead." + ) + update_pyproject_toml( pyproject_toml_config_option_parser, "emoji", toml_file=toml_file ) @@ -646,8 +658,14 @@ def use_scipy_parser( """Modify the configuration file to use the Scipy parser.""" def _use_scipy_parser( - toml_file: Path | str = pyproject_toml_file, + toml_file: Path | str = pyproject_toml_file, monorepo: bool = False ) -> type[CommitParser[ParseResult, ParserOptions]]: + if monorepo: + raise ValueError( + "The Scipy parser does not support monorepo mode. " + "Use the conventional parser instead." + ) + update_pyproject_toml( pyproject_toml_config_option_parser, "scipy", toml_file=toml_file ) diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index f76f83455..613955290 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -2,6 +2,7 @@ import os import sys +from contextlib import suppress from copy import deepcopy from datetime import datetime, timedelta from functools import reduce @@ -64,6 +65,9 @@ from semantic_release.commit_parser.conventional import ( ConventionalCommitParser, ) + from semantic_release.commit_parser.conventional.parser_monorepo import ( + ConventionalCommitMonorepoParser, + ) from semantic_release.commit_parser.emoji import EmojiCommitParser from semantic_release.commit_parser.scipy import ScipyCommitParser from semantic_release.commit_parser.token import ParsedMessageResult, ParseResult @@ -73,6 +77,7 @@ GetParserFromConfigFileFn, UpdateVersionPyFileFn, ) + from tests.fixtures.monorepos.git_monorepo import BuildMonorepoFn try: # Python 3.8 and 3.9 compatibility @@ -137,6 +142,7 @@ class CommitDef(TypedDict): sha: str datetime: NotRequired[DatetimeISOStr] include_in_changelog: bool + file_to_change: NotRequired[Path | str] class BaseRepoVersionDef(TypedDict): """A Common Repo definition for a get_commits_repo_*() fixture with all commit convention types""" @@ -155,6 +161,7 @@ def __call__( extra_configs: dict[str, TomlSerializableTypes] | None = None, mask_initial_release: bool = True, # Default as of v10 package_name: str = ..., + monorepo: bool = False, ) -> tuple[Path, HvcsBase]: ... class CommitNReturnChangelogEntryFn(Protocol): @@ -284,6 +291,7 @@ class CommitSpec(TypedDict): scipy: str datetime: NotRequired[DatetimeISOStr] include_in_changelog: NotRequired[bool] + file_to_change: NotRequired[Path | str] class DetailsBase(TypedDict): pre_actions: NotRequired[Sequence[RepoActions]] @@ -301,6 +309,34 @@ class RepoActionConfigureDetails(DetailsBase): mask_initial_release: bool extra_configs: dict[str, TomlSerializableTypes] + class RepoActionConfigureMonorepo(TypedDict): + action: Literal[RepoActionStep.CONFIGURE_MONOREPO] + details: RepoActionConfigureMonorepoDetails + + class RepoActionConfigureMonorepoDetails(DetailsBase): + package_dir: Path | str + package_name: str + tag_format_str: str | None + mask_initial_release: bool + extra_configs: dict[str, TomlSerializableTypes] + + class RepoActionCreateMonorepo(TypedDict): + action: Literal[RepoActionStep.CREATE_MONOREPO] + details: RepoActionCreateMonorepoDetails + + class RepoActionCreateMonorepoDetails(DetailsBase): + commit_type: CommitConvention + hvcs_client_name: str + hvcs_domain: str + origin_url: NotRequired[str] + + class RepoActionChangeDirectory(TypedDict): + action: Literal[RepoActionStep.CHANGE_DIRECTORY] + details: RepoActionChangeDirectoryDetails + + class RepoActionChangeDirectoryDetails(DetailsBase): + directory: Path | str + class RepoActionMakeCommits(TypedDict): action: Literal[RepoActionStep.MAKE_COMMITS] details: RepoActionMakeCommitsDetails @@ -381,6 +417,7 @@ def __call__( commit_spec: CommitSpec, commit_type: CommitConvention, parser: CommitParser[ParseResult, ParserOptions], + monorepo: bool = ..., ) -> CommitDef: ... class GetRepoDefinitionFn(Protocol): @@ -415,6 +452,7 @@ def __call__( commits: Sequence[CommitSpec], commit_type: CommitConvention, parser: CommitParser[ParseResult, ParserOptions], + monorepo: bool = ..., ) -> Sequence[CommitDef]: ... class BuildSpecificRepoFn(Protocol): @@ -423,7 +461,10 @@ def __call__( ) -> Sequence[RepoActions]: ... RepoActions: TypeAlias = Union[ + RepoActionChangeDirectory, RepoActionConfigure, + RepoActionConfigureMonorepo, + RepoActionCreateMonorepo, RepoActionGitCheckout, RepoActionGitMerge[RepoActionGitMergeDetails], RepoActionGitMerge[RepoActionGitFFMergeDetails], @@ -528,15 +569,28 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: # the implementation on Windows holds some file descriptors open until close is called. with Repo.init(cached_repo_path) as repo: rmtree(str(Path(repo.git_dir, "hooks"))) + + # set up remote origin (including a refs directory) + git_origin_dir = Path(repo.common_dir, "refs", "remotes", "origin") + repo.create_remote(name=git_origin_dir.name, url=example_git_https_url) + git_origin_dir.mkdir(parents=True, exist_ok=True) + # Without this the global config may set it to "master", we want consistency repo.git.branch("-M", DEFAULT_BRANCH_NAME) + with repo.config_writer("repository") as config: config.set_value("user", "name", commit_author.name) config.set_value("user", "email", commit_author.email) config.set_value("commit", "gpgsign", False) config.set_value("tag", "gpgsign", False) - repo.create_remote(name="origin", url=example_git_https_url) + # set up a remote tracking branch for the default branch + config.set_value(f'branch "{DEFAULT_BRANCH_NAME}"', "remote", "origin") + config.set_value( + f'branch "{DEFAULT_BRANCH_NAME}"', + "merge", + f"refs/heads/{DEFAULT_BRANCH_NAME}", + ) # make sure all base files are in index to enable initial commit repo.index.add(("*", ".gitignore")) @@ -611,6 +665,43 @@ def _get_commit_def(msg: str, parser: ConventionalCommitParser) -> CommitDef: return _get_commit_def +@pytest.fixture(scope="session") +def get_commit_def_of_conventional_commit_monorepo() -> ( + GetCommitDefFn[ConventionalCommitMonorepoParser] +): + def _get_commit_def( + msg: str, parser: ConventionalCommitMonorepoParser + ) -> CommitDef: + if not (parsed_result := parser.parse_message(msg)): + return { + "cid": "", + "msg": msg, + "type": "unknown", + "category": "Unknown", + "desc": msg, + "brking_desc": "", + "scope": "", + "mr": "", + "sha": NULL_HEX_SHA, + "include_in_changelog": False, + } + + return { + "cid": "", + "msg": msg, + "type": parsed_result.type, + "category": parsed_result.category, + "desc": str.join("\n\n", parsed_result.descriptions), + "brking_desc": str.join("\n\n", parsed_result.breaking_descriptions), + "scope": parsed_result.scope, + "mr": parsed_result.linked_merge_request, + "sha": NULL_HEX_SHA, + "include_in_changelog": True, + } + + return _get_commit_def + + @pytest.fixture(scope="session") def get_commit_def_of_emoji_commit() -> GetCommitDefFn[EmojiCommitParser]: def _get_commit_def_of_emoji_commit( @@ -906,6 +997,15 @@ def _create_squash_merge_commit( date=commit_dt.isoformat(timespec="seconds"), ) + # After a merge we need to ensure the remote tracking branch is updated + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # return the commit definition with the sha & message updated return { **commit_def, @@ -955,6 +1055,14 @@ def _mimic_semantic_release_commit( date=commit_dt.isoformat(timespec="seconds"), ) + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # ensure commit timestamps are unique (adding one second even though a nanosecond has gone by) commit_dt += timedelta(seconds=1) @@ -992,6 +1100,14 @@ def _commit_n_rtn_changelog_entry( date=commit_dt.isoformat(timespec="seconds"), ) + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # Capture the resulting commit message and sha return { **commit_def, @@ -1013,7 +1129,9 @@ def _simulate_change_commits_n_rtn_changelog_entry( changelog_entries: list[CommitDef] = [] for commit_msg in commit_msgs: if not git_repo.is_dirty(index=True, working_tree=False): - add_text_to_file(git_repo, file_in_repo) + add_text_to_file( + git_repo, str(commit_msg.get("file_to_change", file_in_repo)) + ) changelog_entries.append(commit_n_rtn_changelog_entry(git_repo, commit_msg)) @@ -1078,6 +1196,7 @@ def _build_configured_base_repo( # noqa: C901 extra_configs: dict[str, TomlSerializableTypes] | None = None, mask_initial_release: bool = True, # Default as of v10 package_name: str = EXAMPLE_PROJECT_NAME, + monorepo: bool = False, ) -> tuple[Path, HvcsBase]: if not cached_example_git_project.exists(): raise RuntimeError("Unable to find cached git project files!") @@ -1094,6 +1213,7 @@ def _build_configured_base_repo( # noqa: C901 extra_configs=extra_configs, mask_initial_release=mask_initial_release, package_name=package_name, + monorepo=monorepo, ) return _build_configured_base_repo @@ -1131,16 +1251,19 @@ def _configure_base_repo( # noqa: C901 extra_configs: dict[str, TomlSerializableTypes] | None = None, mask_initial_release: bool = True, # Default as of v10 package_name: str = EXAMPLE_PROJECT_NAME, + monorepo: bool = False, ) -> tuple[Path, HvcsBase]: # Make sure we are in the dest directory with temporary_working_directory(dest_dir): # Set parser configuration if commit_type == "conventional": - use_conventional_parser(toml_file=pyproject_toml_file) + use_conventional_parser( + toml_file=pyproject_toml_file, monorepo=monorepo + ) elif commit_type == "emoji": - use_emoji_parser(toml_file=pyproject_toml_file) + use_emoji_parser(toml_file=pyproject_toml_file, monorepo=monorepo) elif commit_type == "scipy": - use_scipy_parser(toml_file=pyproject_toml_file) + use_scipy_parser(toml_file=pyproject_toml_file, monorepo=monorepo) else: use_custom_parser(commit_type, toml_file=pyproject_toml_file) @@ -1217,39 +1340,6 @@ def _configure_base_repo( # noqa: C901 @pytest.fixture(scope="session") def separate_squashed_commit_def() -> SeparateSquashedCommitDefFn: - # default_conventional_parser: ConventionalCommitParser, - # default_emoji_parser: EmojiCommitParser, - # default_scipy_parser: ScipyCommitParser, - # message_parsers: dict[ - # CommitConvention, - # ConventionalCommitParser | EmojiCommitParser | ScipyCommitParser, - # ] = { - # "conventional": ConventionalCommitParser( - # options=ConventionalCommitParserOptions( - # **{ - # **default_conventional_parser.options.__dict__, - # "parse_squash_commits": True, - # } - # ) - # ), - # "emoji": EmojiCommitParser( - # options=EmojiParserOptions( - # **{ - # **default_emoji_parser.options.__dict__, - # "parse_squash_commits": True, - # } - # ) - # ), - # "scipy": ScipyCommitParser( - # options=ScipyParserOptions( - # **{ - # **default_scipy_parser.options.__dict__, - # "parse_squash_commits": True, - # } - # ) - # ), - # } - def _separate_squashed_commit_def( squashed_commit_def: CommitDef, parser: SquashedCommitSupportedParser, @@ -1290,12 +1380,16 @@ def _separate_squashed_commit_def( @pytest.fixture(scope="session") def convert_commit_spec_to_commit_def( get_commit_def_of_conventional_commit: GetCommitDefFn[ConventionalCommitParser], + get_commit_def_of_conventional_commit_monorepo: GetCommitDefFn[ + ConventionalCommitMonorepoParser + ], get_commit_def_of_emoji_commit: GetCommitDefFn[EmojiCommitParser], get_commit_def_of_scipy_commit: GetCommitDefFn[ScipyCommitParser], stable_now_date: datetime, ) -> ConvertCommitSpecToCommitDefFn: message_parsers = { "conventional": get_commit_def_of_conventional_commit, + "conventional-monorepo": get_commit_def_of_conventional_commit_monorepo, "emoji": get_commit_def_of_emoji_commit, "scipy": get_commit_def_of_scipy_commit, } @@ -1304,11 +1398,15 @@ def _convert( commit_spec: CommitSpec, commit_type: CommitConvention, parser: CommitParser[ParseResult, ParserOptions], + monorepo: bool = False, ) -> CommitDef: - parse_msg_fn = cast("GetCommitDefFn[Any]", message_parsers[commit_type]) + parse_msg_fn = cast( + "GetCommitDefFn[Any]", + message_parsers[f"{commit_type}{'-monorepo' if monorepo else ''}"], + ) # Extract the correct commit message for the commit type - return { + commit_def: CommitDef = { **parse_msg_fn(commit_spec[commit_type], parser=parser), "cid": commit_spec["cid"], "datetime": ( @@ -1316,9 +1414,18 @@ def _convert( if "datetime" in commit_spec else stable_now_date.isoformat(timespec="seconds") ), - "include_in_changelog": (commit_spec.get("include_in_changelog", True)), + "include_in_changelog": commit_spec.get("include_in_changelog", True), } + if "file_to_change" in commit_spec: + commit_def.update( + { + "file_to_change": commit_spec["file_to_change"], + } + ) + + return commit_def + return _convert @@ -1330,9 +1437,12 @@ def _convert( commits: Sequence[CommitSpec], commit_type: CommitConvention, parser: CommitParser[ParseResult, ParserOptions], + monorepo: bool = False, ) -> Sequence[CommitDef]: return [ - convert_commit_spec_to_commit_def(commit, commit_type, parser=parser) + convert_commit_spec_to_commit_def( + commit, commit_type, parser=parser, monorepo=monorepo + ) for commit in commits ] @@ -1342,6 +1452,8 @@ def _convert( @pytest.fixture(scope="session") def build_repo_from_definition( # noqa: C901, its required and its just test code build_configured_base_repo: BuildRepoFn, + build_base_monorepo: BuildMonorepoFn, + configure_monorepo_package: BuildRepoFn, default_tag_format_str: str, create_release_tagged_commit: CreateReleaseFn, create_squash_merge_commit: CreateSquashMergeCommitFn, @@ -1386,6 +1498,7 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c ) repo_dir = Path(dest_dir).resolve().absolute() + commit_type: CommitConvention = "conventional" hvcs: Github | Gitlab | Gitea | Bitbucket commit_cache: dict[str, CommitDef] = {} current_repo_def: dict[Version | Literal["Unreleased"], RepoVersionDef] = {} @@ -1418,6 +1531,64 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c }, ) + elif action == RepoActionStep.CREATE_MONOREPO: + cfg_mr_def = cast( + "RepoActionCreateMonorepoDetails", step_result["details"] + ) + build_base_monorepo(dest_dir=repo_dir) + hvcs = get_hvcs( + hvcs_client_name=cfg_mr_def["hvcs_client_name"], + origin_url=cfg_mr_def.get("origin_url") + or example_git_https_url, + hvcs_domain=cfg_mr_def["hvcs_domain"], + ) + commit_type = cfg_mr_def["commit_type"] + + elif action == RepoActionStep.CONFIGURE_MONOREPO: + cfg_mr_pkg_def = cast( + "RepoActionConfigureMonorepoDetails", step_result["details"] + ) + configure_monorepo_package( + dest_dir=cfg_mr_pkg_def["package_dir"], + commit_type=commit_type, + hvcs_client_name=hvcs.__class__.__name__.lower(), + hvcs_domain=str(hvcs.hvcs_domain), + tag_format_str=cfg_mr_pkg_def["tag_format_str"], + extra_configs=cfg_mr_pkg_def["extra_configs"], + mask_initial_release=cfg_mr_pkg_def["mask_initial_release"], + package_name=cfg_mr_pkg_def["package_name"], + monorepo=True, + ) + + elif action == RepoActionStep.CHANGE_DIRECTORY: + change_dir_def = cast( + "RepoActionChangeDirectoryDetails", step_result["details"] + ) + if not ( + new_cwd := Path(change_dir_def["directory"]) + .resolve() + .absolute() + ).exists(): + msg = f"Directory {change_dir_def['directory']} does not exist." + raise NotADirectoryError(msg) + + # Helpful Transform to find the project root repo without needing to pass it around (ie '/' => repo_dir) + new_cwd = ( + repo_dir if str(new_cwd) == str(repo_dir.root) else new_cwd + ) + + if not new_cwd.is_dir(): + msg = f"Path {change_dir_def['directory']} is not a directory." + raise NotADirectoryError(msg) + + # TODO: 3.9+, use is_relative_to + # if not new_cwd.is_relative_to(repo_dir): + if repo_dir != new_cwd and repo_dir not in new_cwd.parents: + msg = f"Cannot change directory to '{new_cwd}' as it is outside the repo directory '{repo_dir}'." + raise ValueError(msg) + + os.chdir(str(new_cwd)) + elif action == RepoActionStep.MAKE_COMMITS: mk_cmts_def = cast( "RepoActionMakeCommitsDetails", step_result["details"] @@ -1528,6 +1699,18 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c create_branch_def["name"], commit=start_head.commit, ) + # set up a remote tracking branch for the new branch + with git_repo.config_writer("repository") as config: + config.set_value( + f'branch "{create_branch_def["name"]}"', + "remote", + "origin", + ) + config.set_value( + f'branch "{create_branch_def["name"]}"', + "merge", + f'refs/heads/{create_branch_def["name"]}', + ) new_branch_head.checkout() elif "branch" in ckout_def: @@ -1571,19 +1754,19 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c ) elif action == RepoActionStep.GIT_MERGE: - this_step = cast("RepoActionGitMerge", step_result) + this_step = cast( + "RepoActionGitMerge[RepoActionGitFFMergeDetails | RepoActionGitMergeDetails]", + step_result, + ) with Repo(repo_dir) as git_repo: if this_step["details"]["fast_forward"]: - ff_merge_def = cast( - "RepoActionGitFFMergeDetails", this_step["details"] + git_repo.git.merge( + this_step["details"]["branch_name"], ff=True ) - git_repo.git.merge(ff_merge_def["branch_name"], ff=True) else: - merge_def = cast( - "RepoActionGitMergeDetails", this_step["details"] - ) + merge_def = this_step["details"] # Update the commit definition with the repo hash merge_def["commit_def"] = create_merge_commit( @@ -1609,6 +1792,15 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c } ) + # After a merge we need to ensure the remote tracking branch is updated + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + else: raise ValueError(f"Unknown action: {action}") @@ -1735,7 +1927,14 @@ def _split_repo_actions_by_release_tags( # Loop through all actions and split them by release tags for step in repo_definition: - if any(step["action"] == action for action in [RepoActionStep.CONFIGURE]): + if any( + step["action"] == action + for action in [ + RepoActionStep.CONFIGURE, + RepoActionStep.CREATE_MONOREPO, + RepoActionStep.CONFIGURE_MONOREPO, + ] + ): releasetags_2_steps[None].append(step) continue @@ -1754,6 +1953,7 @@ def _split_repo_actions_by_release_tags( insignificant_actions = [ RepoActionStep.GIT_CHECKOUT, + RepoActionStep.CHANGE_DIRECTORY, ] # Remove Unreleased if there are no significant steps in an Unreleased section diff --git a/tests/fixtures/monorepos/__init__.py b/tests/fixtures/monorepos/__init__.py new file mode 100644 index 000000000..e3546c89f --- /dev/null +++ b/tests/fixtures/monorepos/__init__.py @@ -0,0 +1,4 @@ +from tests.fixtures.monorepos.example_monorepo import * +from tests.fixtures.monorepos.git_monorepo import * +from tests.fixtures.monorepos.github_flow import * +from tests.fixtures.monorepos.trunk_based_dev import * diff --git a/tests/fixtures/monorepos/example_monorepo.py b/tests/fixtures/monorepos/example_monorepo.py new file mode 100644 index 000000000..1b46bde2b --- /dev/null +++ b/tests/fixtures/monorepos/example_monorepo.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING + +import pytest + +# NOTE: use backport with newer API +import tests.conftest +import tests.const +import tests.fixtures.example_project +import tests.util +from tests.const import ( + EXAMPLE_PROJECT_NAME, + EXAMPLE_PROJECT_VERSION, + EXAMPLE_PYPROJECT_TOML_CONTENT, + EXAMPLE_RELEASE_NOTES_TEMPLATE, +) +from tests.util import copy_dir_tree, temporary_working_directory + +if TYPE_CHECKING: + from typing import Any, Protocol, Sequence + + from tests.conftest import ( + BuildRepoOrCopyCacheFn, + GetMd5ForSetOfFilesFn, + ) + from tests.fixtures.example_project import ( + UpdatePyprojectTomlFn, + UpdateVersionPyFileFn, + ) + from tests.fixtures.git_repo import RepoActions + + # class GetWheelFileFn(Protocol): + # def __call__(self, version_str: str) -> Path: ... + + class UpdatePkgPyprojectTomlFn(Protocol): + def __call__(self, pkg_name: str, setting: str, value: Any) -> None: ... + + class UseCommonReleaseNotesTemplateFn(Protocol): + def __call__(self) -> None: ... + + +@pytest.fixture(scope="session") +def deps_files_4_example_monorepo() -> list[Path]: + return [ + # This file + Path(__file__).absolute(), + # because of imports + Path(tests.const.__file__).absolute(), + Path(tests.util.__file__).absolute(), + # because of the fixtures + Path(tests.conftest.__file__).absolute(), + Path(tests.fixtures.example_project.__file__).absolute(), + ] + + +@pytest.fixture(scope="session") +def build_spec_hash_4_example_monorepo( + get_md5_for_set_of_files: GetMd5ForSetOfFilesFn, + deps_files_4_example_monorepo: list[Path], +) -> str: + # Generates a hash of the build spec to set when to invalidate the cache + return get_md5_for_set_of_files(deps_files_4_example_monorepo) + + +@pytest.fixture(scope="session") +def cached_example_monorepo( + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + monorepo_pkg1_name: str, + monorepo_pkg2_name: str, + monorepo_pkg1_dir: str, + monorepo_pkg2_dir: str, + monorepo_pkg1_docs_dir: str, + monorepo_pkg2_docs_dir: str, + monorepo_pkg1_version_py_file: Path, + monorepo_pkg2_version_py_file: Path, + monorepo_pkg1_pyproject_toml_file: Path, + monorepo_pkg2_pyproject_toml_file: Path, + build_spec_hash_4_example_monorepo: str, + update_version_py_file: UpdateVersionPyFileFn, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> Path: + """ + Initializes the example monorepo project. DO NOT USE DIRECTLY + + Use the `init_example_monorepo` fixture instead. + """ + + def _build_project(cached_project_path: Path) -> Sequence[RepoActions]: + # purposefully a relative path + # example_dir = version_py_file.parent + gitignore_contents = dedent( + f""" + *.pyc + /{monorepo_pkg1_version_py_file} + /{monorepo_pkg2_version_py_file} + dist/ + """ + ).lstrip() + init_py_contents = dedent( + ''' + """An example package with a very informative docstring.""" + from ._version import __version__ + + def hello_world() -> None: + print("{pkg_name} Hello World") + ''' + ).lstrip() + doc_index_contents = dedent( + """ + ================== + {pkg_name} Documentation + ================== + """ + ).lstrip() + + with temporary_working_directory(cached_project_path): + update_version_py_file( + version=EXAMPLE_PROJECT_VERSION, + version_file=monorepo_pkg1_version_py_file, + ) + update_version_py_file( + version=EXAMPLE_PROJECT_VERSION, + version_file=monorepo_pkg2_version_py_file, + ) + + file_2_contents: list[tuple[str | Path, str]] = [ + ( + monorepo_pkg1_version_py_file.parent / "__init__.py", + init_py_contents.format(pkg_name="Pkg 1:"), + ), + ( + monorepo_pkg2_version_py_file.parent / "__init__.py", + init_py_contents.format(pkg_name="Pkg 2:"), + ), + (".gitignore", gitignore_contents), + (monorepo_pkg1_pyproject_toml_file, EXAMPLE_PYPROJECT_TOML_CONTENT), + (monorepo_pkg2_pyproject_toml_file, EXAMPLE_PYPROJECT_TOML_CONTENT), + ( + Path(monorepo_pkg1_docs_dir, "index.rst"), + doc_index_contents.format(pkg_name=monorepo_pkg1_name), + ), + ( + Path(monorepo_pkg2_docs_dir, "index.rst"), + doc_index_contents.format(pkg_name=monorepo_pkg2_name), + ), + ] + + for file, contents in file_2_contents: + abs_filepath = cached_project_path.joinpath(file).resolve() + # make sure the parent directory exists + abs_filepath.parent.mkdir(parents=True, exist_ok=True) + # write file contents + abs_filepath.write_text(contents) + + config_updates: list[tuple[str, Any, Path]] = [ + ( + "tool.poetry.name", + "pkg-1", + cached_project_path / monorepo_pkg1_pyproject_toml_file, + ), + ( + "tool.poetry.name", + "pkg-2", + cached_project_path / monorepo_pkg2_pyproject_toml_file, + ), + ( + "tool.semantic_release.version_variables", + [ + f"{monorepo_pkg1_version_py_file.relative_to(monorepo_pkg1_dir)}:__version__" + ], + cached_project_path / monorepo_pkg1_pyproject_toml_file, + ), + ( + "tool.semantic_release.version_variables", + [ + f"{monorepo_pkg2_version_py_file.relative_to(monorepo_pkg2_dir)}:__version__" + ], + cached_project_path / monorepo_pkg2_pyproject_toml_file, + ), + ] + + for setting, value, toml_file in config_updates: + update_pyproject_toml( + setting=setting, + value=value, + toml_file=toml_file, + ) + + # This is a special build, we don't expose the Repo Actions to the caller + return [] + + # End of _build_project() + + return build_repo_or_copy_cache( + repo_name="example_monorepo", + build_spec_hash=build_spec_hash_4_example_monorepo, + build_repo_func=_build_project, + ) + + +@pytest.fixture +def init_example_monorepo( + example_project_dir: tests.fixtures.example_project.ExProjectDir, + cached_example_monorepo: Path, + change_to_ex_proj_dir: None, +) -> None: + """This fixture initializes the example project in the current test's project directory.""" + if not cached_example_monorepo.exists(): + raise RuntimeError( + f"Unable to find cached project files for {EXAMPLE_PROJECT_NAME}" + ) + + # Copy the cached project files into the current test's project directory + copy_dir_tree(cached_example_monorepo, example_project_dir) + + +@pytest.fixture +def monorepo_project_w_common_release_notes_template( + init_example_monorepo: None, + monorepo_use_common_release_notes_template: UseCommonReleaseNotesTemplateFn, +) -> None: + monorepo_use_common_release_notes_template() + + +@pytest.fixture(scope="session") +def monorepo_pkg1_name() -> str: + return "pkg1" + + +@pytest.fixture(scope="session") +def monorepo_pkg2_name() -> str: + return "pkg2" + + +@pytest.fixture(scope="session") +def monorepo_pkg_docs_dir_pattern() -> str: + return str(Path("docs", "source", "{package_name}")) + + +@pytest.fixture(scope="session") +def monorepo_pkg1_docs_dir( + monorepo_pkg1_name: str, + monorepo_pkg_docs_dir_pattern: str, +) -> str: + return monorepo_pkg_docs_dir_pattern.format(package_name=monorepo_pkg1_name) + + +@pytest.fixture(scope="session") +def monorepo_pkg2_docs_dir( + monorepo_pkg2_name: str, + monorepo_pkg_docs_dir_pattern: str, +) -> str: + return monorepo_pkg_docs_dir_pattern.format(package_name=monorepo_pkg2_name) + + +@pytest.fixture(scope="session") +def monorepo_pkg_dir_pattern() -> str: + return str(Path("packages", "{package_name}")) + + +@pytest.fixture(scope="session") +def monorepo_pkg1_dir( + monorepo_pkg1_name: str, + monorepo_pkg_dir_pattern: str, +) -> str: + return monorepo_pkg_dir_pattern.format(package_name=monorepo_pkg1_name) + + +@pytest.fixture(scope="session") +def monorepo_pkg2_dir( + monorepo_pkg2_name: str, + monorepo_pkg_dir_pattern: str, +) -> str: + return monorepo_pkg_dir_pattern.format(package_name=monorepo_pkg2_name) + + +@pytest.fixture(scope="session") +def monorepo_pkg_version_py_file_pattern(monorepo_pkg_dir_pattern: str) -> str: + return str(Path(monorepo_pkg_dir_pattern, "src", "{package_name}", "_version.py")) + + +@pytest.fixture(scope="session") +def monorepo_pkg1_version_py_file( + monorepo_pkg1_name: str, + monorepo_pkg_version_py_file_pattern: str, +) -> Path: + return Path( + monorepo_pkg_version_py_file_pattern.format(package_name=monorepo_pkg1_name) + ) + + +@pytest.fixture(scope="session") +def monorepo_pkg2_version_py_file( + monorepo_pkg2_name: str, + monorepo_pkg_version_py_file_pattern: str, +) -> Path: + return Path( + monorepo_pkg_version_py_file_pattern.format(package_name=monorepo_pkg2_name) + ) + + +@pytest.fixture(scope="session") +def monorepo_pkg_pyproject_toml_file_pattern( + monorepo_pkg_dir_pattern: str, + pyproject_toml_file: str, +) -> str: + return str(Path(monorepo_pkg_dir_pattern, pyproject_toml_file)) + + +@pytest.fixture(scope="session") +def monorepo_pkg1_pyproject_toml_file( + monorepo_pkg1_name: str, + monorepo_pkg_pyproject_toml_file_pattern: str, +) -> Path: + return Path( + monorepo_pkg_pyproject_toml_file_pattern.format(package_name=monorepo_pkg1_name) + ) + + +@pytest.fixture(scope="session") +def monorepo_pkg2_pyproject_toml_file( + monorepo_pkg2_name: str, + monorepo_pkg_pyproject_toml_file_pattern: str, +) -> Path: + return Path( + monorepo_pkg_pyproject_toml_file_pattern.format(package_name=monorepo_pkg2_name) + ) + + +@pytest.fixture(scope="session") +def monorepo_pkg_dist_dir_pattern(monorepo_pkg_dir_pattern: str) -> str: + return str(Path(monorepo_pkg_dir_pattern, "dist")) + + +@pytest.fixture(scope="session") +def monorepo_pkg1_dist_dir( + monorepo_pkg1_name: str, + monorepo_pkg_dist_dir_pattern: str, +) -> Path: + return Path(monorepo_pkg_dist_dir_pattern.format(package_name=monorepo_pkg1_name)) + + +@pytest.fixture(scope="session") +def monorepo_pkg2_dist_dir( + monorepo_pkg2_name: str, + monorepo_pkg_dist_dir_pattern: str, +) -> Path: + return Path(monorepo_pkg_dist_dir_pattern.format(package_name=monorepo_pkg2_name)) + + +@pytest.fixture(scope="session") +def monorepo_pkg_changelog_md_file_pattern(monorepo_pkg_dir_pattern: str) -> str: + return str(Path(monorepo_pkg_dir_pattern, "CHANGELOG.md")) + + +@pytest.fixture(scope="session") +def monorepo_pkg1_changelog_md_file( + monorepo_pkg1_name: str, + monorepo_pkg_changelog_md_file_pattern: str, +) -> Path: + return Path( + monorepo_pkg_changelog_md_file_pattern.format(package_name=monorepo_pkg1_name) + ) + + +@pytest.fixture(scope="session") +def monorepo_pkg2_changelog_md_file( + monorepo_pkg2_name: str, + monorepo_pkg_changelog_md_file_pattern: str, +) -> Path: + return Path( + monorepo_pkg_changelog_md_file_pattern.format(package_name=monorepo_pkg2_name) + ) + + +@pytest.fixture(scope="session") +def monorepo_pkg_changelog_rst_file_pattern(monorepo_pkg_dir_pattern: str) -> str: + return str(Path(monorepo_pkg_dir_pattern, "CHANGELOG.rst")) + + +@pytest.fixture(scope="session") +def monorepo_pkg1_changelog_rst_file( + monorepo_pkg1_name: str, + monorepo_pkg_changelog_rst_file_pattern: str, +) -> Path: + return Path( + monorepo_pkg_changelog_rst_file_pattern.format(package_name=monorepo_pkg1_name) + ) + + +@pytest.fixture(scope="session") +def monorepo_pkg2_changelog_rst_file( + monorepo_pkg2_name: str, + monorepo_pkg_changelog_rst_file_pattern: str, +) -> Path: + return Path( + monorepo_pkg_changelog_rst_file_pattern.format(package_name=monorepo_pkg2_name) + ) + + +# @pytest.fixture(scope="session") +# def get_wheel_file(dist_dir: Path) -> GetWheelFileFn: +# def _get_wheel_file(version_str: str) -> Path: +# return dist_dir / f"{EXAMPLE_PROJECT_NAME}-{version_str}-py3-none-any.whl" + +# return _get_wheel_file + + +@pytest.fixture +def example_monorepo_pkg_dir_pattern( + tmp_path: Path, + monorepo_pkg_dir_pattern: Path, +) -> str: + return str(tmp_path.resolve() / monorepo_pkg_dir_pattern) + + +@pytest.fixture +def example_monorepo_pkg1_dir( + monorepo_pkg1_name: str, + example_monorepo_pkg_dir_pattern: str, +) -> Path: + return Path( + example_monorepo_pkg_dir_pattern.format(package_name=monorepo_pkg1_name) + ) + + +@pytest.fixture +def example_monorepo_pkg2_dir( + monorepo_pkg2_name: str, + example_monorepo_pkg_dir_pattern: str, +) -> Path: + return Path( + example_monorepo_pkg_dir_pattern.format(package_name=monorepo_pkg2_name) + ) + + +@pytest.fixture +def monorepo_use_common_release_notes_template( + example_project_template_dir: Path, + changelog_template_dir: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, + monorepo_pkg1_pyproject_toml_file: Path, + monorepo_pkg2_pyproject_toml_file: Path, +) -> UseCommonReleaseNotesTemplateFn: + config_setting_template_dir = "tool.semantic_release.changelog.template_dir" + + def _use_release_notes_template() -> None: + update_pyproject_toml( + setting=config_setting_template_dir, + value=str( + Path( + *( + "../" + for _ in list(Path(monorepo_pkg1_pyproject_toml_file).parents)[ + :-1 + ] + ), + changelog_template_dir, + ) + ), + toml_file=monorepo_pkg1_pyproject_toml_file, + ) + + update_pyproject_toml( + setting=config_setting_template_dir, + value=str( + Path( + *( + "../" + for _ in list(Path(monorepo_pkg2_pyproject_toml_file).parents)[ + :-1 + ] + ), + changelog_template_dir, + ) + ), + toml_file=monorepo_pkg2_pyproject_toml_file, + ) + + example_project_template_dir.mkdir(parents=True, exist_ok=True) + release_notes_j2 = example_project_template_dir / ".release_notes.md.j2" + release_notes_j2.write_text(EXAMPLE_RELEASE_NOTES_TEMPLATE) + + return _use_release_notes_template + + +# @pytest.fixture +# def example_pyproject_toml( +# example_project_dir: ExProjectDir, +# pyproject_toml_file: Path, +# ) -> Path: +# return example_project_dir / pyproject_toml_file + + +# @pytest.fixture +# def example_dist_dir( +# example_project_dir: ExProjectDir, +# dist_dir: Path, +# ) -> Path: +# return example_project_dir / dist_dir + + +# @pytest.fixture +# def example_project_wheel_file( +# example_dist_dir: Path, +# get_wheel_file: GetWheelFileFn, +# ) -> Path: +# return example_dist_dir / get_wheel_file(EXAMPLE_PROJECT_VERSION) + + +# Note this is just the path and the content may change +# @pytest.fixture +# def example_changelog_md( +# example_project_dir: ExProjectDir, +# changelog_md_file: Path, +# ) -> Path: +# return example_project_dir / changelog_md_file + + +# Note this is just the path and the content may change +# @pytest.fixture +# def example_changelog_rst( +# example_project_dir: ExProjectDir, +# changelog_rst_file: Path, +# ) -> Path: +# return example_project_dir / changelog_rst_file + + +# @pytest.fixture +# def example_project_template_dir( +# example_project_dir: ExProjectDir, +# changelog_template_dir: Path, +# ) -> Path: +# return example_project_dir / changelog_template_dir + + +@pytest.fixture(scope="session") +def update_pkg_pyproject_toml( + update_pyproject_toml: UpdatePyprojectTomlFn, + monorepo_pkg_pyproject_toml_file_pattern: str, +) -> UpdatePkgPyprojectTomlFn: + """Update the pyproject.toml file with the given content.""" + + def _update_pyproject_toml(pkg_name: str, setting: str, value: Any) -> None: + toml_file = Path( + monorepo_pkg_pyproject_toml_file_pattern.format(package_name=pkg_name) + ).resolve() + + if not toml_file.exists(): + raise FileNotFoundError( + f"pyproject.toml file for package {pkg_name} not found at {toml_file}" + ) + + update_pyproject_toml( + setting=setting, + value=value, + toml_file=toml_file, + ) + + return _update_pyproject_toml diff --git a/tests/fixtures/monorepos/git_monorepo.py b/tests/fixtures/monorepos/git_monorepo.py new file mode 100644 index 000000000..59da65df9 --- /dev/null +++ b/tests/fixtures/monorepos/git_monorepo.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from pathlib import Path +from shutil import rmtree +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +import tests.conftest +import tests.const +import tests.fixtures.git_repo +import tests.util +from tests.const import ( + DEFAULT_BRANCH_NAME, + EXAMPLE_HVCS_DOMAIN, + EXAMPLE_PROJECT_NAME, +) +from tests.util import copy_dir_tree + +if TYPE_CHECKING: + from typing import Protocol, Sequence + + from git import Actor + + from semantic_release.hvcs import HvcsBase + + from tests.conftest import ( + BuildRepoOrCopyCacheFn, + GetMd5ForSetOfFilesFn, + RepoActions, + ) + from tests.fixtures.git_repo import ( + BuildRepoFn, + CommitConvention, + TomlSerializableTypes, + ) + + class BuildMonorepoFn(Protocol): + def __call__(self, dest_dir: Path | str) -> Path: ... + + +@pytest.fixture(scope="session") +def deps_files_4_example_git_monorepo( + deps_files_4_example_monorepo: list[Path], +) -> list[Path]: + return [ + *deps_files_4_example_monorepo, + # This file + Path(__file__).absolute(), + # because of imports + Path(tests.const.__file__).absolute(), + Path(tests.util.__file__).absolute(), + # because of the fixtures + Path(tests.conftest.__file__).absolute(), + Path(tests.fixtures.git_repo.__file__).absolute(), + ] + + +@pytest.fixture(scope="session") +def build_spec_hash_4_example_git_monorepo( + get_md5_for_set_of_files: GetMd5ForSetOfFilesFn, + deps_files_4_example_git_monorepo: list[Path], +) -> str: + # Generates a hash of the build spec to set when to invalidate the cache + return get_md5_for_set_of_files(deps_files_4_example_git_monorepo) + + +@pytest.fixture(scope="session") +def cached_example_git_monorepo( + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_example_git_monorepo: str, + cached_example_monorepo: Path, + example_git_https_url: str, + commit_author: Actor, +) -> Path: + """ + Initializes an example monorepo project with git. DO NOT USE DIRECTLY. + + Use a `repo_*` fixture instead. This creates a default + base repository, all settings can be changed later through from the + example_project_git_repo fixture's return object and manual adjustment. + """ + + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + if not cached_example_monorepo.exists(): + raise RuntimeError("Unable to find cached monorepo files") + + # make a copy of the example monorepo as a base + copy_dir_tree(cached_example_monorepo, cached_repo_path) + + # initialize git repo (open and close) + # NOTE: We don't want to hold the repo object open for the entire test session, + # the implementation on Windows holds some file descriptors open until close is called. + with Repo.init(cached_repo_path) as repo: + rmtree(str(Path(repo.git_dir, "hooks"))) + + # set up remote origin (including a refs directory) + git_origin_dir = Path(repo.common_dir, "refs", "remotes", "origin") + repo.create_remote(name=git_origin_dir.name, url=example_git_https_url) + git_origin_dir.mkdir(parents=True, exist_ok=True) + + # Without this the global config may set it to "master", we want consistency + repo.git.branch("-M", DEFAULT_BRANCH_NAME) + + with repo.config_writer("repository") as config: + config.set_value("user", "name", commit_author.name) + config.set_value("user", "email", commit_author.email) + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + # set up a remote tracking branch for the default branch + config.set_value(f'branch "{DEFAULT_BRANCH_NAME}"', "remote", "origin") + config.set_value( + f'branch "{DEFAULT_BRANCH_NAME}"', + "merge", + f"refs/heads/{DEFAULT_BRANCH_NAME}", + ) + + # make sure all base files are in index to enable initial commit + repo.index.add(("*", ".gitignore")) + + # This is a special build, we don't expose the Repo Actions to the caller + return [] + + # End of _build_repo() + + return build_repo_or_copy_cache( + repo_name=cached_example_git_monorepo.__name__.split("_", maxsplit=1)[1], + build_spec_hash=build_spec_hash_4_example_git_monorepo, + build_repo_func=_build_repo, + ) + + +@pytest.fixture(scope="session") +def file_in_pkg_pattern(file_in_repo: str, monorepo_pkg_dir_pattern: str) -> str: + return str(Path(monorepo_pkg_dir_pattern) / file_in_repo) + + +@pytest.fixture(scope="session") +def file_in_monorepo_pkg1( + monorepo_pkg1_name: str, + file_in_pkg_pattern: str, +) -> Path: + return Path(file_in_pkg_pattern.format(pkg_name=monorepo_pkg1_name)) + + +@pytest.fixture(scope="session") +def file_in_monorepo_pkg2( + monorepo_pkg2_name: str, + file_in_pkg_pattern: str, +) -> Path: + return Path(file_in_pkg_pattern.format(pkg_name=monorepo_pkg2_name)) + + +@pytest.fixture(scope="session") +def build_base_monorepo( # noqa: C901 + cached_example_git_monorepo: Path, +) -> BuildMonorepoFn: + """ + This fixture is intended to simplify repo scenario building by initially + creating the repo but also configuring semantic_release in the pyproject.toml + for when the test executes semantic_release. It returns a function so that + derivative fixtures can call this fixture with individual parameters. + """ + + def _build_configured_base_monorepo(dest_dir: Path | str) -> Path: + if not cached_example_git_monorepo.exists(): + raise RuntimeError("Unable to find cached git project files!") + + # Copy the cached git project the dest directory + copy_dir_tree(cached_example_git_monorepo, dest_dir) + + return Path(dest_dir) + + return _build_configured_base_monorepo + + +@pytest.fixture(scope="session") +def configure_monorepo_package( # noqa: C901 + configure_base_repo: BuildRepoFn, +) -> BuildRepoFn: + """ + This fixture is intended to simplify repo scenario building by initially + creating the repo but also configuring semantic_release in the pyproject.toml + for when the test executes semantic_release. It returns a function so that + derivative fixtures can call this fixture with individual parameters. + """ + + def _configure( # noqa: C901 + dest_dir: Path | str, + commit_type: CommitConvention = "conventional", + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + mask_initial_release: bool = True, # Default as of v10 + package_name: str = EXAMPLE_PROJECT_NAME, + monorepo: bool = True, + ) -> tuple[Path, HvcsBase]: + if not monorepo: + raise ValueError("This fixture is only for monorepo packages!") + + if not Path(dest_dir).exists(): + raise RuntimeError(f"Destination directory {dest_dir} does not exist!") + + return configure_base_repo( + dest_dir=dest_dir, + commit_type=commit_type, + hvcs_client_name=hvcs_client_name, + hvcs_domain=hvcs_domain, + tag_format_str=tag_format_str, + extra_configs=extra_configs, + mask_initial_release=mask_initial_release, + package_name=package_name, + monorepo=monorepo, + ) + + return _configure diff --git a/tests/fixtures/monorepos/github_flow/__init__.py b/tests/fixtures/monorepos/github_flow/__init__.py new file mode 100644 index 000000000..3b951b378 --- /dev/null +++ b/tests/fixtures/monorepos/github_flow/__init__.py @@ -0,0 +1,2 @@ +from tests.fixtures.monorepos.github_flow.monorepo_w_default_release import * +from tests.fixtures.monorepos.github_flow.monorepo_w_release_channels import * diff --git a/tests/fixtures/monorepos/github_flow/monorepo_w_default_release.py b/tests/fixtures/monorepos/github_flow/monorepo_w_default_release.py new file mode 100644 index 000000000..ecbe491ed --- /dev/null +++ b/tests/fixtures/monorepos/github_flow/monorepo_w_default_release.py @@ -0,0 +1,967 @@ +from __future__ import annotations + +from datetime import timedelta +from itertools import count +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING, cast + +import pytest + +from semantic_release.cli.config import ChangelogOutputFormat +from semantic_release.commit_parser.conventional.options_monorepo import ( + ConventionalCommitMonorepoParserOptions, +) +from semantic_release.commit_parser.conventional.parser_monorepo import ( + ConventionalCommitMonorepoParser, +) +from semantic_release.version.version import Version + +import tests.conftest +import tests.const +import tests.util +from tests.const import ( + DEFAULT_BRANCH_NAME, + EXAMPLE_HVCS_DOMAIN, + INITIAL_COMMIT_MESSAGE, + RepoActionStep, +) + +if TYPE_CHECKING: + from typing import Sequence + + from semantic_release.commit_parser._base import CommitParser, ParserOptions + from semantic_release.commit_parser.token import ParseResult + + from tests.conftest import ( + GetCachedRepoDataFn, + GetMd5ForSetOfFilesFn, + GetStableDateNowFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildRepoOrCopyCacheFn, + BuildSpecificRepoFn, + BuiltRepoResult, + CommitConvention, + CommitSpec, + ConvertCommitSpecsToCommitDefsFn, + ConvertCommitSpecToCommitDefFn, + ExProjectGitRepoFn, + FormatGitHubSquashCommitMsgFn, + GetRepoDefinitionFn, + RepoActionChangeDirectory, + RepoActions, + RepoActionWriteChangelogsDestFile, + TomlSerializableTypes, + ) + + +@pytest.fixture(scope="session") +def deps_files_4_github_flow_monorepo_w_default_release_channel( + deps_files_4_example_git_monorepo: list[Path], +) -> list[Path]: + return [ + *deps_files_4_example_git_monorepo, + # This file + Path(__file__).absolute(), + # because of imports + Path(tests.const.__file__).absolute(), + Path(tests.util.__file__).absolute(), + # because of the fixtures + Path(tests.conftest.__file__).absolute(), + ] + + +@pytest.fixture(scope="session") +def build_spec_hash_4_github_flow_monorepo_w_default_release_channel( + get_md5_for_set_of_files: GetMd5ForSetOfFilesFn, + deps_files_4_github_flow_monorepo_w_default_release_channel: list[Path], +) -> str: + # Generates a hash of the build spec to set when to invalidate the cache + return get_md5_for_set_of_files( + deps_files_4_github_flow_monorepo_w_default_release_channel + ) + + +@pytest.fixture(scope="session") +def get_repo_definition_4_github_flow_monorepo_w_default_release_channel( + convert_commit_specs_to_commit_defs: ConvertCommitSpecsToCommitDefsFn, + convert_commit_spec_to_commit_def: ConvertCommitSpecToCommitDefFn, + format_squash_commit_msg_github: FormatGitHubSquashCommitMsgFn, + monorepo_pkg1_changelog_md_file: Path, + monorepo_pkg1_changelog_rst_file: Path, + monorepo_pkg2_changelog_md_file: Path, + monorepo_pkg2_changelog_rst_file: Path, + monorepo_pkg1_name: str, + monorepo_pkg2_name: str, + monorepo_pkg1_dir: str, + monorepo_pkg2_dir: str, + monorepo_pkg1_docs_dir: str, + monorepo_pkg2_docs_dir: str, + monorepo_pkg1_version_py_file: Path, + monorepo_pkg2_version_py_file: Path, + monorepo_pkg1_pyproject_toml_file: Path, + monorepo_pkg2_pyproject_toml_file: Path, + stable_now_date: GetStableDateNowFn, + default_tag_format_str: str, +) -> GetRepoDefinitionFn: + """ + Builds a Monorepo with the GitHub Flow branching strategy and a squash commit merging strategy + for a single release channel on the default branch. + + Implementation: + - The monorepo contains two packages, each with its own internal changelog but shared template. + - The repository implements the following git graph: + + ``` + * chore(release): pkg1@1.1.0 [skip ci] (tag: pkg1-v1.1.0, branch: main, HEAD -> main) + * feat(pkg1): file modified outside of pkg 1, identified by scope (#5) + | + | * docs: pkg1 docs modified outside of pkg 1, identified by path filter (branch: pkg1/feat/pr-4) + | * feat(pkg1): file modified outside of pkg 1, identified by scope + |/ + * chore(release): pkg2@1.1.1 [skip ci] (tag: pkg2-v1.1.1) + * fix(pkg2-cli): file modified outside of pkg 2, identified by scope (#4) + | + | * fix(pkg2-cli): file modified outside of pkg 2, identified by scope (branch: pkg2/fix/pr-3) + |/ + * chore(release): pkg2@1.1.0 [skip ci] (tag: pkg2-v1.1.0) + * feat: no pkg scope but file in pkg 2 directory (#3) # Squash merge of pkg2/feat/pr-2 + * chore(release): pkg1@1.0.1 [skip ci] (tag: pkg1-v1.0.1) + * fix: no pkg scope but file in pkg 1 directory (#2) # Squash merge of pkg1/fix/pr-1 + | + | * docs(cli): add cli documentation + | * test(cli): add cli tests + | * feat: no pkg scope but file in pkg 2 directory (branch: pkg2/feat/pr-2) + |/ + | * fix: no pkg scope but file in pkg 1 directory (branch: pkg1/fix/pr-1) + |/ + * chore(release): pkg2@1.0.0 [skip ci] (tag: pkg2-v1.0.0) # Initial release of pkg 2 + * chore(release): pkg1@1.0.0 [skip ci] (tag: pkg1-v1.0.0) # Initial release of pkg 1 + * Initial commit # Includes core functionality for both packages + ``` + """ + + def _get_repo_from_definition( + commit_type: CommitConvention, + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = default_tag_format_str, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, + ) -> Sequence[RepoActions]: + stable_now_datetime = stable_now_date() + commit_timestamp_gen = ( + (stable_now_datetime + timedelta(seconds=i)).isoformat(timespec="seconds") + for i in count(step=1) + ) + pr_num_gen = (i for i in count(start=2, step=1)) + + pkg1_changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ + { + "path": monorepo_pkg1_changelog_md_file, + "format": ChangelogOutputFormat.MARKDOWN, + "mask_initial_release": True, + }, + { + "path": monorepo_pkg1_changelog_rst_file, + "format": ChangelogOutputFormat.RESTRUCTURED_TEXT, + "mask_initial_release": True, + }, + ] + + pkg2_changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ + { + "path": monorepo_pkg2_changelog_md_file, + "format": ChangelogOutputFormat.MARKDOWN, + "mask_initial_release": True, + }, + { + "path": monorepo_pkg2_changelog_rst_file, + "format": ChangelogOutputFormat.RESTRUCTURED_TEXT, + "mask_initial_release": True, + }, + ] + + change_to_pkg1_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": monorepo_pkg1_dir, + }, + } + + change_to_pkg2_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": monorepo_pkg2_dir, + }, + } + + change_to_example_project_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": "/", + }, + } + + if commit_type != "conventional": + raise ValueError(f"Unsupported commit type: {commit_type}") + + pkg1_path_filters = (".", f"../../{monorepo_pkg1_docs_dir}") + pkg1_commit_parser = ConventionalCommitMonorepoParser( + options=ConventionalCommitMonorepoParserOptions( + parse_squash_commits=True, + ignore_merge_commits=ignore_merge_commits, + scope_prefix=f"{monorepo_pkg1_name}-?", + path_filters=pkg1_path_filters, + ) + ) + + pkg2_path_filters = (".", f"../../{monorepo_pkg2_docs_dir}") + pkg2_commit_parser = ConventionalCommitMonorepoParser( + options=ConventionalCommitMonorepoParserOptions( + parse_squash_commits=pkg1_commit_parser.options.parse_squash_commits, + ignore_merge_commits=pkg1_commit_parser.options.ignore_merge_commits, + scope_prefix=f"{monorepo_pkg2_name}-?", + path_filters=pkg2_path_filters, + ) + ) + + common_configs: dict[str, TomlSerializableTypes] = { + # Set the default release branch + "tool.semantic_release.branches.main": { + "match": r"^(main|master)$", + "prerelease": False, + }, + "tool.semantic_release.allow_zero_version": False, + "tool.semantic_release.changelog.exclude_commit_patterns": [r"^chore"], + "tool.semantic_release.commit_parser": f"{commit_type}-monorepo", + "tool.semantic_release.commit_parser_options.parse_squash_commits": pkg1_commit_parser.options.parse_squash_commits, + "tool.semantic_release.commit_parser_options.ignore_merge_commits": pkg1_commit_parser.options.ignore_merge_commits, + } + + mr1_pkg1_fix_branch_name = f"{monorepo_pkg1_name}/fix/pr-1" + mr2_pkg2_feat_branch_name = f"{monorepo_pkg2_name}/feat/pr-2" + mr3_pkg2_fix_branch_name = f"{monorepo_pkg2_name}/fix/pr-3" + mr4_pkg1_feat_branch_name = f"{monorepo_pkg1_name}/feat/pr-4" + + pkg1_new_version = Version.parse( + "1.0.0", tag_format=f"{monorepo_pkg1_name}-{tag_format_str}" + ) + pkg2_new_version = Version.parse( + "1.0.0", tag_format=f"{monorepo_pkg2_name}-{tag_format_str}" + ) + + repo_construction_steps: list[RepoActions] = [ + { + "action": RepoActionStep.CREATE_MONOREPO, + "details": { + "commit_type": commit_type, + "hvcs_client_name": hvcs_client_name, + "hvcs_domain": hvcs_domain, + "post_actions": [ + { + "action": RepoActionStep.CONFIGURE_MONOREPO, + "details": { + "package_dir": monorepo_pkg1_dir, + "package_name": monorepo_pkg1_name, + "tag_format_str": pkg1_new_version.tag_format, + "mask_initial_release": mask_initial_release, + "extra_configs": { + **common_configs, + "tool.semantic_release.commit_message": ( + pkg1_cmt_msg_format := dedent( + f"""\ + chore(release): {monorepo_pkg1_name}@{{version}} [skip ci] + + Automatically generated by python-semantic-release + """ + ) + ), + "tool.semantic_release.commit_parser_options.scope_prefix": pkg1_commit_parser.options.scope_prefix, + "tool.semantic_release.commit_parser_options.path_filters": pkg1_path_filters, + **(extra_configs or {}), + }, + }, + }, + { + "action": RepoActionStep.CONFIGURE_MONOREPO, + "details": { + "package_dir": monorepo_pkg2_dir, + "package_name": monorepo_pkg2_name, + "tag_format_str": pkg2_new_version.tag_format, + "mask_initial_release": mask_initial_release, + "extra_configs": { + **common_configs, + "tool.semantic_release.commit_message": ( + pkg2_cmt_msg_format := dedent( + f"""\ + chore(release): {monorepo_pkg2_name}@{{version}} [skip ci] + + Automatically generated by python-semantic-release + """ + ) + ), + "tool.semantic_release.commit_parser_options.scope_prefix": pkg2_commit_parser.options.scope_prefix, + "tool.semantic_release.commit_parser_options.path_filters": pkg2_path_filters, + **(extra_configs or {}), + }, + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": ( + cid_c1_initial := "c1_initial_commit" + ), + "conventional": INITIAL_COMMIT_MESSAGE, + "emoji": INITIAL_COMMIT_MESSAGE, + "scipy": INITIAL_COMMIT_MESSAGE, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": bool( + commit_type == "emoji" + ), + }, + ], + commit_type, + # this parser does not matter since the commit is common + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + }, + }, + ], + }, + } + ] + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [cid_c1_initial], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [cid_c1_initial], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + pkg1_fix_branch_commits: Sequence[CommitSpec] = [ + { + "cid": "pkg1-fix-1-squashed", + "conventional": "fix: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "emoji": ":bug: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "scipy": "MAINT: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "datetime": next(commit_timestamp_gen), + }, + ] + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": { + "create_branch": { + "name": mr1_pkg1_fix_branch_name, + "start_branch": DEFAULT_BRANCH_NAME, + } + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "pre_actions": [change_to_pkg1_dir], + "commits": convert_commit_specs_to_commit_defs( + [ + { + **commit, + "include_in_changelog": False, + } + for commit in pkg1_fix_branch_commits + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + # simulate separate work by another person at same time as the fix branch + pkg2_feat_branch_commits: Sequence[CommitSpec] = [ + { + "cid": "pkg2-feat-1-squashed", + "conventional": "feat: no pkg scope but file in pkg 2 directory", + "emoji": ":sparkles: no pkg scope but file in pkg 2 directory", + "scipy": "ENH: no pkg scope but file in pkg 2 directory", + "datetime": next(commit_timestamp_gen), + }, + { + "cid": "pkg2-feat-2-squashed", + "conventional": "test(cli): add cli tests", + "emoji": ":checkmark: add cli tests", + "scipy": "TST: add cli tests", + "datetime": next(commit_timestamp_gen), + }, + { + "cid": "pkg2-feat-3-squashed", + "conventional": "docs(cli): add cli documentation", + "emoji": ":memo: add cli documentation", + "scipy": "DOC: add cli documentation", + "datetime": next(commit_timestamp_gen), + }, + ] + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": { + "create_branch": { + "name": mr2_pkg2_feat_branch_name, + "start_branch": DEFAULT_BRANCH_NAME, + }, + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "pre_actions": [change_to_pkg2_dir], + "commits": convert_commit_specs_to_commit_defs( + [ + { + **commit, + "include_in_changelog": False, + } + for commit in pkg2_feat_branch_commits + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + pkg1_new_version = Version.parse( + "1.0.1", tag_format=pkg1_new_version.tag_format + ) + + all_commit_types: list[CommitConvention] = ["conventional", "emoji", "scipy"] + fix_branch_pr_number = next(pr_num_gen) + fix_branch_squash_commit_spec: CommitSpec = { + "cid": "mr1-pkg1-fix", + **{ # type: ignore[typeddict-item] + cmt_type: format_squash_commit_msg_github( + # Use the primary commit message as the PR title + pr_title=pkg1_fix_branch_commits[0][cmt_type], + pr_number=fix_branch_pr_number, + squashed_commits=[ + cmt[commit_type] for cmt in pkg1_fix_branch_commits[1:] + ], + ) + for cmt_type in all_commit_types + }, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + } + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + { + "action": RepoActionStep.GIT_SQUASH, + "details": { + "branch": mr1_pkg1_fix_branch_name, + "strategy_option": "theirs", + "commit_def": convert_commit_spec_to_commit_def( + fix_branch_squash_commit_spec, + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + "config_file": monorepo_pkg1_pyproject_toml_file, + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [ + f'{fix_branch_squash_commit_spec["cid"]}-{index + 1}' + for index in range(len(pkg1_fix_branch_commits)) + ], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + feat_branch_pr_number = next(pr_num_gen) + feat_branch_squash_commit_spec: CommitSpec = { + "cid": "mr2-pkg2-feat", + **{ # type: ignore[typeddict-item] + cmt_type: format_squash_commit_msg_github( + # Use the primary commit message as the PR title + pr_title=pkg2_feat_branch_commits[0][cmt_type], + pr_number=feat_branch_pr_number, + squashed_commits=[ + cmt[commit_type] for cmt in pkg2_feat_branch_commits[1:] + ], + ) + for cmt_type in all_commit_types + }, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + } + + pkg2_new_version = Version.parse( + "1.1.0", tag_format=pkg2_new_version.tag_format + ) + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_SQUASH, + "details": { + "branch": mr2_pkg2_feat_branch_name, + "strategy_option": "theirs", + "commit_def": convert_commit_spec_to_commit_def( + feat_branch_squash_commit_spec, + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + "config_file": monorepo_pkg2_pyproject_toml_file, + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [ + f'{feat_branch_squash_commit_spec["cid"]}-{index + 1}' + for index in range( + len(pkg2_feat_branch_commits) + ) + ], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + pkg2_fix_branch_commits: Sequence[CommitSpec] = [ + { + "cid": "pkg2-fix-1-squashed", + "conventional": "fix(pkg2-cli): file modified outside of pkg 2, identified by scope\n\nResolves: #123\n", + "emoji": ":bug: (pkg2-cli) file modified outside of pkg 2, identified by scope\n\nResolves: #123\n", + "scipy": "MAINT:pkg2-cli: file modified outside of pkg 2, identified by scope\n\nResolves: #123\n", + "datetime": next(commit_timestamp_gen), + }, + ] + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": { + "create_branch": { + "name": mr3_pkg2_fix_branch_name, + "start_branch": DEFAULT_BRANCH_NAME, + } + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + **commit, + "include_in_changelog": False, + } + for commit in pkg2_fix_branch_commits + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + }, + }, + ] + ) + + pkg2_new_version = Version.parse( + "1.1.1", tag_format=pkg2_new_version.tag_format + ) + + fix_branch_pr_number = next(pr_num_gen) + fix_branch_squash_commit_spec = { + "cid": "mr3-pkg2-fix", + **{ # type: ignore[typeddict-item] + cmt_type: format_squash_commit_msg_github( + # Use the primary commit message as the PR title + pr_title=pkg2_fix_branch_commits[0][cmt_type], + pr_number=fix_branch_pr_number, + squashed_commits=[ + cmt[commit_type] for cmt in pkg2_fix_branch_commits[1:] + ], + ) + for cmt_type in all_commit_types + }, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + } + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + { + "action": RepoActionStep.GIT_SQUASH, + "details": { + "branch": mr3_pkg2_fix_branch_name, + "strategy_option": "theirs", + "commit_def": convert_commit_spec_to_commit_def( + fix_branch_squash_commit_spec, + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + "config_file": monorepo_pkg2_pyproject_toml_file, + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [ + f'{fix_branch_squash_commit_spec["cid"]}-{index + 1}' + for index in range(len(pkg2_fix_branch_commits)) + ], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + pkg1_feat_branch_commits: Sequence[CommitSpec] = [ + { + "cid": "pkg1-feat-1-squashed", + "conventional": "feat(pkg1): file modified outside of pkg 1, identified by scope", + "emoji": ":sparkles: (pkg1) file modified outside of pkg 1, identified by scope", + "scipy": "ENH:pkg1: file modified outside of pkg 1, identified by scope", + "datetime": next(commit_timestamp_gen), + }, + { + "cid": "pkg1-docs-2-squashed", + "conventional": "docs: pkg1 docs modified outside of pkg 1, identified by path filter", + "emoji": ":book: pkg1 docs modified outside of pkg 1, identified by path filter", + "scipy": "DOC: pkg1 docs modified outside of pkg 1, identified by path filter", + "datetime": next(commit_timestamp_gen), + "file_to_change": f"{monorepo_pkg1_docs_dir}/index.rst", + }, + ] + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": { + "create_branch": { + "name": mr4_pkg1_feat_branch_name, + "start_branch": DEFAULT_BRANCH_NAME, + }, + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + **commit, + "include_in_changelog": False, + } + for commit in pkg1_feat_branch_commits + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + }, + }, + ] + ) + + feat_branch_pr_number = next(pr_num_gen) + feat_branch_squash_commit_spec = { + "cid": "mr4-pkg1-feat", + **{ # type: ignore[typeddict-item] + cmt_type: format_squash_commit_msg_github( + # Use the primary commit message as the PR title + pr_title=pkg1_feat_branch_commits[0][cmt_type], + pr_number=feat_branch_pr_number, + squashed_commits=[ + cmt[commit_type] for cmt in pkg1_feat_branch_commits[1:] + ], + ) + for cmt_type in all_commit_types + }, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + } + + pkg1_new_version = Version.parse( + "1.1.0", tag_format=pkg1_new_version.tag_format + ) + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + { + "action": RepoActionStep.GIT_SQUASH, + "details": { + "branch": mr4_pkg1_feat_branch_name, + "strategy_option": "theirs", + "commit_def": convert_commit_spec_to_commit_def( + feat_branch_squash_commit_spec, + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + "config_file": monorepo_pkg1_pyproject_toml_file, + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [ + f'{feat_branch_squash_commit_spec["cid"]}-{index + 1}' + for index in range( + len(pkg1_feat_branch_commits) + ) + ], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + return repo_construction_steps + + return _get_repo_from_definition + + +@pytest.fixture(scope="session") +def build_monorepo_w_github_flow_w_default_release_channel( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_github_flow_monorepo_w_default_release_channel: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_github_flow_monorepo_w_default_release_channel: str, +) -> BuildSpecificRepoFn: + def _build_specific_repo_type( + repo_name: str, commit_type: CommitConvention, dest_dir: Path + ) -> Sequence[RepoActions]: + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = ( + get_repo_definition_4_github_flow_monorepo_w_default_release_channel( + commit_type=commit_type, + ) + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_github_flow_monorepo_w_default_release_channel, + build_repo_func=_build_repo, + dest_dir=dest_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return cached_repo_data["build_definition"] + + return _build_specific_repo_type + + +# --------------------------------------------------------------------------- # +# Test-level fixtures that will cache the built directory & set up test case # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def monorepo_w_github_flow_w_default_release_channel_conventional_commits( + build_monorepo_w_github_flow_w_default_release_channel: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = ( + monorepo_w_github_flow_w_default_release_channel_conventional_commits.__name__ + ) + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_monorepo_w_github_flow_w_default_release_channel( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } diff --git a/tests/fixtures/monorepos/github_flow/monorepo_w_release_channels.py b/tests/fixtures/monorepos/github_flow/monorepo_w_release_channels.py new file mode 100644 index 000000000..9b4027195 --- /dev/null +++ b/tests/fixtures/monorepos/github_flow/monorepo_w_release_channels.py @@ -0,0 +1,893 @@ +from __future__ import annotations + +from datetime import timedelta +from itertools import count +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING, cast + +import pytest + +from semantic_release.cli.config import ChangelogOutputFormat +from semantic_release.commit_parser.conventional.options_monorepo import ( + ConventionalCommitMonorepoParserOptions, +) +from semantic_release.commit_parser.conventional.parser_monorepo import ( + ConventionalCommitMonorepoParser, +) +from semantic_release.version.version import Version + +import tests.conftest +import tests.const +import tests.util +from tests.const import ( + DEFAULT_BRANCH_NAME, + EXAMPLE_HVCS_DOMAIN, + INITIAL_COMMIT_MESSAGE, + RepoActionStep, +) + +if TYPE_CHECKING: + from typing import Sequence + + from semantic_release.commit_parser._base import CommitParser, ParserOptions + from semantic_release.commit_parser.token import ParseResult + + from tests.conftest import ( + GetCachedRepoDataFn, + GetMd5ForSetOfFilesFn, + GetStableDateNowFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildRepoOrCopyCacheFn, + BuildSpecificRepoFn, + BuiltRepoResult, + CommitConvention, + ConvertCommitSpecsToCommitDefsFn, + ConvertCommitSpecToCommitDefFn, + ExProjectGitRepoFn, + FormatGitHubMergeCommitMsgFn, + GetRepoDefinitionFn, + RepoActionChangeDirectory, + RepoActionGitMerge, + RepoActionGitMergeDetails, + RepoActions, + RepoActionWriteChangelogsDestFile, + TomlSerializableTypes, + ) + + +@pytest.fixture(scope="session") +def deps_files_4_github_flow_monorepo_w_feature_release_channel( + deps_files_4_example_git_monorepo: list[Path], +) -> list[Path]: + return [ + *deps_files_4_example_git_monorepo, + # This file + Path(__file__).absolute(), + # because of imports + Path(tests.const.__file__).absolute(), + Path(tests.util.__file__).absolute(), + # because of the fixtures + Path(tests.conftest.__file__).absolute(), + ] + + +@pytest.fixture(scope="session") +def build_spec_hash_4_github_flow_monorepo_w_feature_release_channel( + get_md5_for_set_of_files: GetMd5ForSetOfFilesFn, + deps_files_4_github_flow_monorepo_w_feature_release_channel: list[Path], +) -> str: + # Generates a hash of the build spec to set when to invalidate the cache + return get_md5_for_set_of_files( + deps_files_4_github_flow_monorepo_w_feature_release_channel + ) + + +@pytest.fixture(scope="session") +def get_repo_definition_4_github_flow_monorepo_w_feature_release_channel( + convert_commit_specs_to_commit_defs: ConvertCommitSpecsToCommitDefsFn, + convert_commit_spec_to_commit_def: ConvertCommitSpecToCommitDefFn, + format_merge_commit_msg_github: FormatGitHubMergeCommitMsgFn, + monorepo_pkg1_changelog_md_file: Path, + monorepo_pkg1_changelog_rst_file: Path, + monorepo_pkg2_changelog_md_file: Path, + monorepo_pkg2_changelog_rst_file: Path, + monorepo_pkg1_name: str, + monorepo_pkg2_name: str, + monorepo_pkg1_dir: str, + monorepo_pkg2_dir: str, + monorepo_pkg1_docs_dir: str, + monorepo_pkg2_docs_dir: str, + monorepo_pkg1_version_py_file: Path, + monorepo_pkg2_version_py_file: Path, + stable_now_date: GetStableDateNowFn, + default_tag_format_str: str, +) -> GetRepoDefinitionFn: + """ + Builds a Monorepo with the GitHub Flow branching strategy and a merge commit merging strategy + for alpha feature releases and official releases on the default branch. + + Implementation: + - The monorepo contains two packages, each with its own internal changelog but shared template. + - The repository implements the following git graph: + + ``` + * chore(release): pkg2@1.1.0 [skip ci] (tag: pkg2-v1.1.0) + * Merge pull request #3 from 'pkg2/feat/pr-2' + |\ + | * chore(release): pkg2@1.1.0-alpha.2 [skip ci] (tag: pkg2-v1.1.0-alpha.2, branch: pkg2/feat/pr-2) + | * fix(pkg2-cli): file modified outside of pkg 2, identified by scope + | * chore(release): pkg2@1.1.0-alpha.1 [skip ci] (tag: pkg2-v1.1.0-alpha.1) + | * docs: pkg2 docs modified outside of pkg 2, identified by path filter + | * test: add cli tests + | * feat: no pkg scope but file in pkg 2 directory + |/ + * chore(release): pkg1@1.0.1 [skip ci] (tag: pkg1-v1.0.1) + * Merge pull request #2 from 'pkg1/fix/pr-1' + |\ + | * chore(release): pkg1@1.0.1-alpha.2 [skip ci] (tag: pkg1-v1.0.1-alpha.2, branch: pkg1/fix/pr-1) + | * fix(pkg1-cli): file modified outside of pkg 1, identified by scope + | * chore(release): pkg1@1.0.1-alpha.1 [skip ci] (tag: pkg1-v1.0.1-alpha.1) + | * fix: no pkg scope but file in pkg 1 directory + |/ + * chore(release): pkg2@1.0.0 [skip ci] (tag: pkg2-v1.0.0) # Initial release of pkg 2 + * chore(release): pkg1@1.0.0 [skip ci] (tag: pkg1-v1.0.0) # Initial release of pkg 1 + * Initial commit # Includes core functionality for both packages + ``` + """ + + def _get_repo_from_definition( + commit_type: CommitConvention, + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = default_tag_format_str, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, + ) -> Sequence[RepoActions]: + stable_now_datetime = stable_now_date() + commit_timestamp_gen = ( + (stable_now_datetime + timedelta(seconds=i)).isoformat(timespec="seconds") + for i in count(step=1) + ) + pr_num_gen = (i for i in count(start=2, step=1)) + + pkg1_changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ + { + "path": monorepo_pkg1_changelog_md_file, + "format": ChangelogOutputFormat.MARKDOWN, + "mask_initial_release": True, + }, + { + "path": monorepo_pkg1_changelog_rst_file, + "format": ChangelogOutputFormat.RESTRUCTURED_TEXT, + "mask_initial_release": True, + }, + ] + + pkg2_changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ + { + "path": monorepo_pkg2_changelog_md_file, + "format": ChangelogOutputFormat.MARKDOWN, + "mask_initial_release": True, + }, + { + "path": monorepo_pkg2_changelog_rst_file, + "format": ChangelogOutputFormat.RESTRUCTURED_TEXT, + "mask_initial_release": True, + }, + ] + + change_to_pkg1_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": monorepo_pkg1_dir, + }, + } + + change_to_pkg2_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": monorepo_pkg2_dir, + }, + } + + change_to_example_project_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": "/", + }, + } + + if commit_type != "conventional": + raise ValueError(f"Unsupported commit type: {commit_type}") + + pkg1_path_filters = (".", f"../../{monorepo_pkg1_docs_dir}") + pkg1_commit_parser = ConventionalCommitMonorepoParser( + options=ConventionalCommitMonorepoParserOptions( + parse_squash_commits=True, + ignore_merge_commits=ignore_merge_commits, + scope_prefix=f"{monorepo_pkg1_name}-?", + path_filters=pkg1_path_filters, + ) + ) + + pkg2_path_filters = (".", f"../../{monorepo_pkg2_docs_dir}") + pkg2_commit_parser = ConventionalCommitMonorepoParser( + options=ConventionalCommitMonorepoParserOptions( + parse_squash_commits=pkg1_commit_parser.options.parse_squash_commits, + ignore_merge_commits=pkg1_commit_parser.options.ignore_merge_commits, + scope_prefix=f"{monorepo_pkg2_name}-?", + path_filters=pkg2_path_filters, + ) + ) + + common_configs: dict[str, TomlSerializableTypes] = { + # Set the default release branch + "tool.semantic_release.branches.main": { + "match": r"^(main|master)$", + "prerelease": False, + }, + "tool.semantic_release.allow_zero_version": False, + "tool.semantic_release.changelog.exclude_commit_patterns": [r"^chore"], + "tool.semantic_release.commit_parser": f"{commit_type}-monorepo", + "tool.semantic_release.commit_parser_options.parse_squash_commits": pkg1_commit_parser.options.parse_squash_commits, + "tool.semantic_release.commit_parser_options.ignore_merge_commits": pkg1_commit_parser.options.ignore_merge_commits, + } + + mr1_pkg1_fix_branch_name = f"{monorepo_pkg1_name}/fix/pr-1" + mr2_pkg2_feat_branch_name = f"{monorepo_pkg2_name}/feat/pr-2" + + pkg1_new_version = Version.parse( + "1.0.0", tag_format=f"{monorepo_pkg1_name}-{tag_format_str}" + ) + pkg2_new_version = Version.parse( + "1.0.0", tag_format=f"{monorepo_pkg2_name}-{tag_format_str}" + ) + + repo_construction_steps: list[RepoActions] = [ + { + "action": RepoActionStep.CREATE_MONOREPO, + "details": { + "commit_type": commit_type, + "hvcs_client_name": hvcs_client_name, + "hvcs_domain": hvcs_domain, + "post_actions": [ + { + "action": RepoActionStep.CONFIGURE_MONOREPO, + "details": { + "package_dir": monorepo_pkg1_dir, + "package_name": monorepo_pkg1_name, + "tag_format_str": pkg1_new_version.tag_format, + "mask_initial_release": mask_initial_release, + "extra_configs": { + **common_configs, + "tool.semantic_release.commit_message": ( + pkg1_cmt_msg_format := dedent( + f"""\ + chore(release): {monorepo_pkg1_name}@{{version}} [skip ci] + + Automatically generated by python-semantic-release + """ + ) + ), + # package branches "feat/" & "fix/" has prerelease suffix of "alpha" + "tool.semantic_release.branches.alpha-release": { + "match": rf"^{monorepo_pkg1_name}/(feat|fix)/.+", + "prerelease": True, + "prerelease_token": "alpha", + }, + "tool.semantic_release.commit_parser_options.scope_prefix": pkg1_commit_parser.options.scope_prefix, + "tool.semantic_release.commit_parser_options.path_filters": pkg1_path_filters, + **(extra_configs or {}), + }, + }, + }, + { + "action": RepoActionStep.CONFIGURE_MONOREPO, + "details": { + "package_dir": monorepo_pkg2_dir, + "package_name": monorepo_pkg2_name, + "tag_format_str": pkg2_new_version.tag_format, + "mask_initial_release": mask_initial_release, + "extra_configs": { + **common_configs, + "tool.semantic_release.commit_message": ( + pkg2_cmt_msg_format := dedent( + f"""\ + chore(release): {monorepo_pkg2_name}@{{version}} [skip ci] + + Automatically generated by python-semantic-release + """ + ) + ), + # package branches "feat/" & "fix/" has prerelease suffix of "alpha" + "tool.semantic_release.branches.alpha-release": { + "match": rf"^{monorepo_pkg2_name}/(feat|fix)/.+", + "prerelease": True, + "prerelease_token": "alpha", + }, + "tool.semantic_release.commit_parser_options.scope_prefix": pkg2_commit_parser.options.scope_prefix, + "tool.semantic_release.commit_parser_options.path_filters": pkg2_path_filters, + **(extra_configs or {}), + }, + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": ( + cid_c1_initial := "c1_initial_commit" + ), + "conventional": INITIAL_COMMIT_MESSAGE, + "emoji": INITIAL_COMMIT_MESSAGE, + "scipy": INITIAL_COMMIT_MESSAGE, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": bool( + commit_type == "emoji" + ), + }, + ], + commit_type, + # this parser does not matter since the commit is common + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + }, + }, + ], + }, + } + ] + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [cid_c1_initial], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [cid_c1_initial], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + # Make a fix in package 1 and release it as an alpha release + pkg1_new_version = Version.parse( + "1.0.1-alpha.1", tag_format=pkg1_new_version.tag_format + ) + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": { + "create_branch": { + "name": mr1_pkg1_fix_branch_name, + "start_branch": DEFAULT_BRANCH_NAME, + } + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "pre_actions": [change_to_pkg1_dir], + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": ( + cid_pkg1_fib1_c1_fix + := "pkg1_fix_branch_1_c1_fix" + ), + "conventional": "fix: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "emoji": ":bug: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "scipy": "MAINT: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + "post_actions": [change_to_example_project_dir], + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [cid_pkg1_fib1_c1_fix], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + # Update the fix in package 1 and release another alpha release + pkg1_new_version = Version.parse( + "1.0.1-alpha.2", tag_format=pkg1_new_version.tag_format + ) + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": ( + cid_pkg1_fib1_c2_fix + := "pkg1_fix_branch_1_c2_fix" + ), + "conventional": "fix(pkg1-cli): file modified outside of pkg 1, identified by scope\n\n", + "emoji": ":bug: (pkg1-cli) file modified outside of pkg 1, identified by scope", + "scipy": "MAINT:pkg1-cli: file modified outside of pkg 1, identified by scope", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [cid_pkg1_fib1_c2_fix], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + # Merge the fix branch into the default branch and formally release it + pkg1_new_version = Version.parse( + "1.0.1", tag_format=pkg1_new_version.tag_format + ) + + merge_def_type_placeholder: RepoActionGitMerge[RepoActionGitMergeDetails] = { + "action": RepoActionStep.GIT_MERGE, + "details": { + "branch_name": mr1_pkg1_fix_branch_name, + "fast_forward": False, + "commit_def": convert_commit_spec_to_commit_def( + { + "cid": (cid_pkg1_fib1_merge := "pkg1_fix_branch_1_merge"), + "conventional": ( + merge_msg := format_merge_commit_msg_github( + pr_number=next(pr_num_gen), + branch_name=mr1_pkg1_fix_branch_name, + ) + ), + "emoji": merge_msg, + "scipy": merge_msg, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": not ignore_merge_commits, + }, + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + }, + } + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + merge_def_type_placeholder, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [cid_pkg1_fib1_merge], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + # Make a feature branch and release it as an alpha release + pkg2_new_version = Version.parse( + "1.1.0-alpha.1", tag_format=pkg2_new_version.tag_format + ) + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": { + "create_branch": { + "name": mr2_pkg2_feat_branch_name, + "start_branch": DEFAULT_BRANCH_NAME, + }, + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "pre_actions": [change_to_pkg2_dir], + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": ( + cid_pkg2_feb1_c1_feat + := "pkg2_feat_branch_1_c1_feat" + ), + "conventional": "feat: no pkg scope but file in pkg 2 directory\n", + "emoji": ":sparkles: no pkg scope but file in pkg 2 directory", + "scipy": "ENH: no pkg scope but file in pkg 2 directory", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + { + "cid": ( + cid_pkg2_feb1_c2_test + := "pkg2_feat_branch_1_c2_test" + ), + "conventional": "test: add cli tests", + "emoji": ":checkmark: add cli tests", + "scipy": "TST: add cli tests", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + { + "cid": ( + cid_pkg2_feb1_c3_docs + := "pkg2_feat_branch_1_c3_docs" + ), + "conventional": "docs: pkg2 docs modified outside of pkg 2, identified by path filter", + "emoji": ":book: pkg2 docs modified outside of pkg 2, identified by path filter", + "scipy": "DOC: pkg2 docs modified outside of pkg 2, identified by path filter", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + "file_to_change": f"../../{monorepo_pkg2_docs_dir}/index.rst", + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + "post_actions": [change_to_example_project_dir], + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [ + cid_pkg2_feb1_c1_feat, + cid_pkg2_feb1_c2_test, + cid_pkg2_feb1_c3_docs, + ], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + # Update the feat with a fix in package 2 and release another alpha release + pkg2_new_version = Version.parse( + "1.1.0-alpha.2", tag_format=pkg2_new_version.tag_format + ) + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": ( + cid_pkg2_feb1_c4_fix + := "pkg2_feat_branch_1_c4_fix" + ), + "conventional": "fix(pkg2-cli): file modified outside of pkg 2, identified by scope", + "emoji": ":bug: (pkg2-cli) file modified outside of pkg 2, identified by scope", + "scipy": "MAINT:pkg2-cli: file modified outside of pkg 2, identified by scope", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [cid_pkg2_feb1_c4_fix], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + # Merge the feat branch into the default branch and formally release a package 2 + pkg2_new_version = Version.parse( + "1.1.0", tag_format=pkg2_new_version.tag_format + ) + + merge_def_type_placeholder = { + "action": RepoActionStep.GIT_MERGE, + "details": { + "branch_name": mr2_pkg2_feat_branch_name, + "fast_forward": False, + "commit_def": convert_commit_spec_to_commit_def( + { + "cid": (cid_pkg2_feb1_merge := "pkg2_feat_branch_1_merge"), + "conventional": ( + merge_msg := format_merge_commit_msg_github( + pr_number=next(pr_num_gen), + branch_name=mr2_pkg2_feat_branch_name, + ) + ), + "emoji": merge_msg, + "scipy": merge_msg, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": not ignore_merge_commits, + }, + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + }, + } + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + merge_def_type_placeholder, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [cid_pkg2_feb1_merge], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + return repo_construction_steps + + return _get_repo_from_definition + + +@pytest.fixture(scope="session") +def build_monorepo_w_github_flow_w_feature_release_channel( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_github_flow_monorepo_w_feature_release_channel: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_github_flow_monorepo_w_feature_release_channel: str, +) -> BuildSpecificRepoFn: + def _build_specific_repo_type( + repo_name: str, commit_type: CommitConvention, dest_dir: Path + ) -> Sequence[RepoActions]: + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = ( + get_repo_definition_4_github_flow_monorepo_w_feature_release_channel( + commit_type=commit_type, + ) + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_github_flow_monorepo_w_feature_release_channel, + build_repo_func=_build_repo, + dest_dir=dest_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return cached_repo_data["build_definition"] + + return _build_specific_repo_type + + +# --------------------------------------------------------------------------- # +# Test-level fixtures that will cache the built directory & set up test case # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def monorepo_w_github_flow_w_feature_release_channel_conventional_commits( + build_monorepo_w_github_flow_w_feature_release_channel: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = ( + monorepo_w_github_flow_w_feature_release_channel_conventional_commits.__name__ + ) + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_monorepo_w_github_flow_w_feature_release_channel( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } diff --git a/tests/fixtures/monorepos/trunk_based_dev/__init__.py b/tests/fixtures/monorepos/trunk_based_dev/__init__.py new file mode 100644 index 000000000..c84f42bc8 --- /dev/null +++ b/tests/fixtures/monorepos/trunk_based_dev/__init__.py @@ -0,0 +1 @@ +from tests.fixtures.monorepos.trunk_based_dev.monorepo_w_tags import * diff --git a/tests/fixtures/monorepos/trunk_based_dev/monorepo_w_tags.py b/tests/fixtures/monorepos/trunk_based_dev/monorepo_w_tags.py new file mode 100644 index 000000000..71bc6e9c7 --- /dev/null +++ b/tests/fixtures/monorepos/trunk_based_dev/monorepo_w_tags.py @@ -0,0 +1,639 @@ +from __future__ import annotations + +from datetime import timedelta +from itertools import count +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING, cast + +import pytest + +from semantic_release.cli.config import ChangelogOutputFormat +from semantic_release.commit_parser.conventional.options_monorepo import ( + ConventionalCommitMonorepoParserOptions, +) +from semantic_release.commit_parser.conventional.parser_monorepo import ( + ConventionalCommitMonorepoParser, +) +from semantic_release.version.version import Version + +import tests.conftest +import tests.const +import tests.util +from tests.const import ( + EXAMPLE_HVCS_DOMAIN, + INITIAL_COMMIT_MESSAGE, + RepoActionStep, +) + +if TYPE_CHECKING: + from typing import Sequence + + from semantic_release.commit_parser._base import CommitParser, ParserOptions + from semantic_release.commit_parser.token import ParseResult + + from tests.conftest import ( + GetCachedRepoDataFn, + GetMd5ForSetOfFilesFn, + GetStableDateNowFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildRepoOrCopyCacheFn, + BuildSpecificRepoFn, + BuiltRepoResult, + CommitConvention, + ConvertCommitSpecsToCommitDefsFn, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + RepoActionChangeDirectory, + RepoActions, + RepoActionWriteChangelogsDestFile, + TomlSerializableTypes, + ) + + +@pytest.fixture(scope="session") +def deps_files_4_trunk_only_monorepo_w_tags( + deps_files_4_example_git_monorepo: list[Path], +) -> list[Path]: + return [ + *deps_files_4_example_git_monorepo, + # This file + Path(__file__).absolute(), + # because of imports + Path(tests.const.__file__).absolute(), + Path(tests.util.__file__).absolute(), + # because of the fixtures + Path(tests.conftest.__file__).absolute(), + ] + + +@pytest.fixture(scope="session") +def build_spec_hash_4_trunk_only_monorepo_w_tags( + get_md5_for_set_of_files: GetMd5ForSetOfFilesFn, + deps_files_4_trunk_only_monorepo_w_tags: list[Path], +) -> str: + # Generates a hash of the build spec to set when to invalidate the cache + return get_md5_for_set_of_files(deps_files_4_trunk_only_monorepo_w_tags) + + +@pytest.fixture(scope="session") +def get_repo_definition_4_trunk_only_monorepo_w_tags( + convert_commit_specs_to_commit_defs: ConvertCommitSpecsToCommitDefsFn, + monorepo_pkg1_changelog_md_file: Path, + monorepo_pkg1_changelog_rst_file: Path, + monorepo_pkg2_changelog_md_file: Path, + monorepo_pkg2_changelog_rst_file: Path, + monorepo_pkg1_name: str, + monorepo_pkg2_name: str, + monorepo_pkg1_dir: str, + monorepo_pkg2_dir: str, + monorepo_pkg1_docs_dir: str, + monorepo_pkg2_docs_dir: str, + monorepo_pkg1_version_py_file: Path, + monorepo_pkg2_version_py_file: Path, + stable_now_date: GetStableDateNowFn, + default_tag_format_str: str, +) -> GetRepoDefinitionFn: + """ + Builds a Monorepo with trunk-based development only with official releases. + + Implementation: + - The monorepo contains two packages, each with its own internal changelog but shared template. + - The repository implements the following git graph: + + ``` + * chore(release): pkg1@0.1.0 [skip ci] (tag: pkg1-v0.1.0, branch: main) + * docs: pkg1 docs modified outside of pkg 1, identified by path filter + * feat(pkg1): file modified outside of pkg 1, identified by scope + * chore(release): pkg2@0.1.1 [skip ci] (tag: pkg2-v0.1.1) + * fix(pkg2-cli): file modified outside of pkg 2, identified by scope + * chore(release): pkg2@0.1.0 [skip ci] (tag: pkg2-v0.1.0) + * docs(pkg2-cli): common docs modified outside of pkg 2, identified by scope + * test: no pkg scope but add tests to package 2 directory + * feat: no pkg scope but file in pkg 2 directory + * chore(release): pkg1@0.0.1 [skip ci] (tag: pkg1-v0.0.1) + * fix: no pkg scope but file in pkg 1 directory + * Initial commit # Includes core functionality for both packages + ``` + """ + + def _get_repo_from_definition( + commit_type: CommitConvention, + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = default_tag_format_str, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, + ) -> Sequence[RepoActions]: + stable_now_datetime = stable_now_date() + commit_timestamp_gen = ( + (stable_now_datetime + timedelta(seconds=i)).isoformat(timespec="seconds") + for i in count(step=1) + ) + + pkg1_changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ + { + "path": monorepo_pkg1_changelog_md_file, + "format": ChangelogOutputFormat.MARKDOWN, + "mask_initial_release": True, + }, + { + "path": monorepo_pkg1_changelog_rst_file, + "format": ChangelogOutputFormat.RESTRUCTURED_TEXT, + "mask_initial_release": True, + }, + ] + + pkg2_changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ + { + "path": monorepo_pkg2_changelog_md_file, + "format": ChangelogOutputFormat.MARKDOWN, + "mask_initial_release": True, + }, + { + "path": monorepo_pkg2_changelog_rst_file, + "format": ChangelogOutputFormat.RESTRUCTURED_TEXT, + "mask_initial_release": True, + }, + ] + + change_to_pkg1_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": monorepo_pkg1_dir, + }, + } + + change_to_pkg2_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": monorepo_pkg2_dir, + }, + } + + change_to_example_project_dir: RepoActionChangeDirectory = { + "action": RepoActionStep.CHANGE_DIRECTORY, + "details": { + "directory": "/", + }, + } + + if commit_type != "conventional": + raise ValueError(f"Unsupported commit type: {commit_type}") + + pkg1_path_filters = (".", f"../../{monorepo_pkg1_docs_dir}") + pkg1_commit_parser = ConventionalCommitMonorepoParser( + options=ConventionalCommitMonorepoParserOptions( + parse_squash_commits=True, + ignore_merge_commits=ignore_merge_commits, + scope_prefix=f"{monorepo_pkg1_name}-?", + path_filters=pkg1_path_filters, + ) + ) + + pkg2_path_filters = (".", f"../../{monorepo_pkg2_docs_dir}") + pkg2_commit_parser = ConventionalCommitMonorepoParser( + options=ConventionalCommitMonorepoParserOptions( + parse_squash_commits=pkg1_commit_parser.options.parse_squash_commits, + ignore_merge_commits=pkg1_commit_parser.options.ignore_merge_commits, + scope_prefix=f"{monorepo_pkg2_name}-?", + path_filters=pkg2_path_filters, + ) + ) + + common_configs: dict[str, TomlSerializableTypes] = { + # Set the default release branch + "tool.semantic_release.branches.main": { + "match": r"^(main|master)$", + "prerelease": False, + }, + "tool.semantic_release.allow_zero_version": True, + "tool.semantic_release.changelog.exclude_commit_patterns": [r"^chore"], + "tool.semantic_release.commit_parser": f"{commit_type}-monorepo", + "tool.semantic_release.commit_parser_options.parse_squash_commits": pkg1_commit_parser.options.parse_squash_commits, + "tool.semantic_release.commit_parser_options.ignore_merge_commits": pkg1_commit_parser.options.ignore_merge_commits, + } + + pkg1_new_version = Version.parse( + "0.0.1", tag_format=f"{monorepo_pkg1_name}-{tag_format_str}" + ) + pkg2_new_version = Version.parse( + "0.1.0", tag_format=f"{monorepo_pkg2_name}-{tag_format_str}" + ) + + repo_construction_steps: list[RepoActions] = [ + { + "action": RepoActionStep.CREATE_MONOREPO, + "details": { + "commit_type": commit_type, + "hvcs_client_name": hvcs_client_name, + "hvcs_domain": hvcs_domain, + "post_actions": [ + { + "action": RepoActionStep.CONFIGURE_MONOREPO, + "details": { + "package_dir": monorepo_pkg1_dir, + "package_name": monorepo_pkg1_name, + "tag_format_str": pkg1_new_version.tag_format, + "mask_initial_release": mask_initial_release, + "extra_configs": { + **common_configs, + "tool.semantic_release.commit_message": ( + pkg1_cmt_msg_format := dedent( + f"""\ + chore(release): {monorepo_pkg1_name}@{{version}} [skip ci] + + Automatically generated by python-semantic-release + """ + ) + ), + "tool.semantic_release.commit_parser_options.scope_prefix": pkg1_commit_parser.options.scope_prefix, + "tool.semantic_release.commit_parser_options.path_filters": pkg1_path_filters, + **(extra_configs or {}), + }, + }, + }, + { + "action": RepoActionStep.CONFIGURE_MONOREPO, + "details": { + "package_dir": monorepo_pkg2_dir, + "package_name": monorepo_pkg2_name, + "tag_format_str": pkg2_new_version.tag_format, + "mask_initial_release": mask_initial_release, + "extra_configs": { + **common_configs, + "tool.semantic_release.commit_message": ( + pkg2_cmt_msg_format := dedent( + f"""\ + chore(release): {monorepo_pkg2_name}@{{version}} [skip ci] + + Automatically generated by python-semantic-release + """ + ) + ), + "tool.semantic_release.commit_parser_options.scope_prefix": pkg2_commit_parser.options.scope_prefix, + "tool.semantic_release.commit_parser_options.path_filters": pkg2_path_filters, + **(extra_configs or {}), + }, + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": ( + cid_c1_initial := "c1_initial_commit" + ), + "conventional": INITIAL_COMMIT_MESSAGE, + "emoji": INITIAL_COMMIT_MESSAGE, + "scipy": INITIAL_COMMIT_MESSAGE, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": bool( + commit_type == "emoji" + ), + }, + ], + commit_type, + # this parser does not matter since the commit is common + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + }, + }, + ], + }, + } + ] + + # Make initial release for package 1 + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "pre_actions": [change_to_pkg1_dir], + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": (cid_c2_pkg1_fix := "c2_pkg1_fix"), + "conventional": "fix: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "emoji": ":bug: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "scipy": "MAINT: no pkg scope but file in pkg 1 directory\n\nResolves: #123\n", + "datetime": next(commit_timestamp_gen), + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + "post_actions": [change_to_example_project_dir], + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [cid_c1_initial, cid_c2_pkg1_fix], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "pre_actions": [change_to_pkg2_dir], + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": (cid_c4_pkg2_feat := "c4_pkg2_feat"), + "conventional": "feat: no pkg scope but file in pkg 2 directory", + "emoji": ":sparkles: no pkg scope but file in pkg 2 directory", + "scipy": "ENH: no pkg scope but file in pkg 2 directory", + "datetime": next(commit_timestamp_gen), + }, + { + "cid": (cid_c5_pkg2_test := "c5_pkg2_test"), + "conventional": "test: no pkg scope but add tests to package 2 directory", + "emoji": ":checkmark: no pkg scope but add tests to package 2 directory", + "scipy": "TST: no pkg scope but add tests to package 2 directory", + "datetime": next(commit_timestamp_gen), + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + "post_actions": [change_to_example_project_dir], + }, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": (cid_c6_pkg2_docs := "c6_pkg2_docs"), + "conventional": "docs(pkg2-cli): common docs modified outside of pkg 2, identified by scope", + "emoji": ":book: (pkg2-cli) common docs modified outside of pkg 2, identified by scope", + "scipy": "DOC:pkg2-cli: common docs modified outside of pkg 2, identified by scope", + "datetime": next(commit_timestamp_gen), + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [ + cid_c1_initial, + cid_c4_pkg2_feat, + cid_c5_pkg2_test, + cid_c6_pkg2_docs, + ], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + pkg2_new_version = Version.parse( + "0.1.1", tag_format=pkg2_new_version.tag_format + ) + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": (cid_c8_pkg2_fix := "c8_pkg2_fix"), + "conventional": "fix(pkg2-cli): file modified outside of pkg 2, identified by scope", + "emoji": ":bug: (pkg2-cli) file modified outside of pkg 2, identified by scope", + "scipy": "MAINT:pkg2-cli: file modified outside of pkg 2, identified by scope", + "datetime": next(commit_timestamp_gen), + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg2_commit_parser, + ), + monorepo=True, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg2_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg2_new_version.tag_format, + "version_py_file": monorepo_pkg2_version_py_file.relative_to( + monorepo_pkg2_dir + ), + "commit_message_format": pkg2_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg2_new_version, + "dest_files": pkg2_changelog_file_definitions, + "commit_ids": [cid_c8_pkg2_fix], + }, + }, + change_to_pkg2_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + pkg1_new_version = Version.parse( + "0.1.0", tag_format=pkg1_new_version.tag_format + ) + + # Add a feature to package 1 and release + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "cid": (cid_c10_pkg1_feat := "c10_pkg1_feat"), + "conventional": "feat(pkg1): file modified outside of pkg 1, identified by scope", + "emoji": ":sparkles: (pkg1) file modified outside of pkg 1, identified by scope", + "scipy": "ENH:pkg1: file modified outside of pkg 1, identified by scope", + "datetime": next(commit_timestamp_gen), + }, + { + "cid": (cid_c11_pkg1_docs := "c11_pkg1_docs"), + "conventional": "docs: pkg1 docs modified outside of pkg 1, identified by path filter", + "emoji": ":book: pkg1 docs modified outside of pkg 1, identified by path filter", + "scipy": "DOC: pkg1 docs modified outside of pkg 1, identified by path filter", + "datetime": next(commit_timestamp_gen), + "file_to_change": f"{monorepo_pkg1_docs_dir}/index.rst", + }, + ], + commit_type, + parser=cast( + "CommitParser[ParseResult, ParserOptions]", + pkg1_commit_parser, + ), + monorepo=True, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": str(pkg1_new_version), + "datetime": next(commit_timestamp_gen), + "tag_format": pkg1_new_version.tag_format, + "version_py_file": monorepo_pkg1_version_py_file.relative_to( + monorepo_pkg1_dir + ), + "commit_message_format": pkg1_cmt_msg_format, + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": pkg1_new_version, + "dest_files": pkg1_changelog_file_definitions, + "commit_ids": [ + cid_c10_pkg1_feat, + cid_c11_pkg1_docs, + ], + }, + }, + change_to_pkg1_dir, + ], + "post_actions": [change_to_example_project_dir], + }, + }, + ] + ) + + return repo_construction_steps + + return _get_repo_from_definition + + +@pytest.fixture(scope="session") +def build_trunk_only_monorepo_w_tags( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_trunk_only_monorepo_w_tags: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_trunk_only_monorepo_w_tags: str, +) -> BuildSpecificRepoFn: + def _build_specific_repo_type( + repo_name: str, commit_type: CommitConvention, dest_dir: Path + ) -> Sequence[RepoActions]: + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = get_repo_definition_4_trunk_only_monorepo_w_tags( + commit_type=commit_type, + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_trunk_only_monorepo_w_tags, + build_repo_func=_build_repo, + dest_dir=dest_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return cached_repo_data["build_definition"] + + return _build_specific_repo_type + + +# --------------------------------------------------------------------------- # +# Test-level fixtures that will cache the built directory & set up test case # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def monorepo_w_trunk_only_releases_conventional_commits( + build_trunk_only_monorepo_w_tags: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = monorepo_w_trunk_only_releases_conventional_commits.__name__ + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_trunk_only_monorepo_w_tags( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } diff --git a/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py b/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py index 4c12f6a72..a36a5eb3f 100644 --- a/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py +++ b/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py @@ -24,7 +24,7 @@ from typing import Any, Generator, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py index 59aa7541a..ba6bd820a 100644 --- a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py @@ -24,7 +24,7 @@ from typing import Any, Generator, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py index cc8b6b42d..bfddc1c82 100644 --- a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py @@ -24,7 +24,7 @@ from typing import Any, Generator, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py index 4cb3ac1f4..1495b6dbd 100644 --- a/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py @@ -24,7 +24,7 @@ from typing import Any, Generator, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/github_flow/repo_w_default_release.py b/tests/fixtures/repos/github_flow/repo_w_default_release.py index 612dac16f..4a7cb1064 100644 --- a/tests/fixtures/repos/github_flow/repo_w_default_release.py +++ b/tests/fixtures/repos/github_flow/repo_w_default_release.py @@ -24,7 +24,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/github_flow/repo_w_default_release_w_branch_update_merge.py b/tests/fixtures/repos/github_flow/repo_w_default_release_w_branch_update_merge.py index b6ca93954..a224aad47 100644 --- a/tests/fixtures/repos/github_flow/repo_w_default_release_w_branch_update_merge.py +++ b/tests/fixtures/repos/github_flow/repo_w_default_release_w_branch_update_merge.py @@ -24,7 +24,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/github_flow/repo_w_release_channels.py b/tests/fixtures/repos/github_flow/repo_w_release_channels.py index 4e3fb87be..ab2f9effa 100644 --- a/tests/fixtures/repos/github_flow/repo_w_release_channels.py +++ b/tests/fixtures/repos/github_flow/repo_w_release_channels.py @@ -24,7 +24,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/repo_initial_commit.py b/tests/fixtures/repos/repo_initial_commit.py index cef6eacfe..baed8fe97 100644 --- a/tests/fixtures/repos/repo_initial_commit.py +++ b/tests/fixtures/repos/repo_initial_commit.py @@ -20,7 +20,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py index a0b6f8b16..cd8761e58 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py @@ -24,7 +24,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py index 557f9f38b..9b1c4c527 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py @@ -24,7 +24,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py index dc2362f39..201cc9c87 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py @@ -22,7 +22,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py b/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py index f9536183d..0f958aff3 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py @@ -23,7 +23,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py index 6121b3699..488e6c28c 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py @@ -23,7 +23,7 @@ from typing import Any, Sequence from semantic_release.commit_parser._base import CommitParser, ParserOptions - from semantic_release.commit_parser.conventional import ( + from semantic_release.commit_parser.conventional.parser import ( ConventionalCommitParser, ) from semantic_release.commit_parser.emoji import EmojiCommitParser diff --git a/tests/gh_action/suite/test_version_ssh_signing.sh b/tests/gh_action/suite/test_version_ssh_signing.sh new file mode 100644 index 000000000..0f0c82c1a --- /dev/null +++ b/tests/gh_action/suite/test_version_ssh_signing.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +__file__="$(realpath "${BASH_SOURCE[0]}")" +__directory__="$(dirname "${__file__}")" + +if ! [ "${UTILS_LOADED}" = "true" ]; then + # shellcheck source=tests/utils.sh + source "$__directory__/../utils.sh" +fi + +test_version_ssh_signing() { + # Test that SSH signing keys are correctly configured in the action + # We will generate an SSH key pair and pass it to the action to ensure + # the ssh-agent and ssh-add commands work correctly + local index="${1:?Index not provided}" + local test_name="${FUNCNAME[0]}" + + # Generate a temporary SSH key pair for testing + local ssh_key_dir + ssh_key_dir="$(mktemp -d)" + local ssh_private_key_file="$ssh_key_dir/signing_key" + local ssh_public_key_file="$ssh_key_dir/signing_key.pub" + + # Generate SSH key pair (Ed25519 for faster generation and smaller keys) + # Note: Using empty passphrase (-N "") for test purposes only + if ! ssh-keygen -t ed25519 -N "" -f "$ssh_private_key_file" -C "test@example.com" >/dev/null 2>&1; then + error "Failed to generate SSH key pair!" + rm -rf "$ssh_key_dir" + return 1 + fi + + # Read the generated keys + local ssh_public_key + local ssh_private_key + ssh_public_key="$(cat "$ssh_public_key_file")" + ssh_private_key="$(cat "$ssh_private_key_file")" + + # Clean up the temporary key files + rm -rf "$ssh_key_dir" + + # Create expectations & set env variables that will be passed in for Docker command + local WITH_VAR_GITHUB_TOKEN="ghp_1x2x3x4x5x6x7x8x9x0x1x2x3x4x5x6x7x8x9x0" + local WITH_VAR_NO_OPERATION_MODE="true" + local WITH_VAR_VERBOSITY="2" + local WITH_VAR_GIT_COMMITTER_NAME="Test User" + local WITH_VAR_GIT_COMMITTER_EMAIL="test@example.com" + local WITH_VAR_SSH_PUBLIC_SIGNING_KEY="$ssh_public_key" + local WITH_VAR_SSH_PRIVATE_SIGNING_KEY="$ssh_private_key" + + # Expected messages in output + local expected_ssh_setup_msg="SSH Key pair found, configuring signing..." + local expected_psr_cmd=".*/bin/semantic-release -vv --noop version" + + # Execute the test & capture output + local output="" + if ! output="$(run_test "$index. $test_name" 2>&1)"; then + # Log the output for debugging purposes + log "$output" + error "fatal error occurred!" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure SSH setup message is present + if ! printf '%s' "$output" | grep -q "$expected_ssh_setup_msg"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find SSH setup message in the output!" + error "\tExpected Message: $expected_ssh_setup_msg" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure ssh-agent was started successfully + if ! printf '%s' "$output" | grep -q "Agent pid"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find ssh-agent start message in the output!" + error "\tExpected Message pattern: 'Agent pid'" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure ssh-add was successful + if ! printf '%s' "$output" | grep -q "Identity added"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find ssh-add success message in the output!" + error "\tExpected Message pattern: 'Identity added'" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure the expected command is present + if ! printf '%s' "$output" | grep -q -E "$expected_psr_cmd"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find the expected command in the output!" + error "\tExpected Command: $expected_psr_cmd" + error "::error:: $test_name failed!" + return 1 + fi + + log "\n$index. $test_name: PASSED!" +} diff --git a/tests/unit/semantic_release/test_gitproject.py b/tests/unit/semantic_release/test_gitproject.py new file mode 100644 index 000000000..58bfedf81 --- /dev/null +++ b/tests/unit/semantic_release/test_gitproject.py @@ -0,0 +1,332 @@ +"""Tests for the GitProject class.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest +from git import GitCommandError + +import semantic_release.gitproject +from semantic_release.errors import ( + DetachedHeadGitError, + GitFetchError, + LocalGitError, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, +) + +if TYPE_CHECKING: + from pathlib import Path + from typing import Generator + + from semantic_release.gitproject import GitProject + + class MockGit(MagicMock): + """A mock Git object that can be used in tests.""" + + rev_parse: MagicMock + fetch: MagicMock + push: MagicMock + + class RepoMock(MagicMock): + """A mock Git repository that can be used in tests.""" + + active_branch: MagicMock + remotes: dict[str, MagicMock] + git: MockGit + git_dir: str + commit: MagicMock + refs: dict[str, MagicMock] + + +@pytest.fixture +def mock_repo(tmp_path: Path) -> RepoMock: + """Create a mock Git repository with proper structure for new implementation.""" + repo = cast("RepoMock", MagicMock()) + + repo.git_dir = str(tmp_path / ".git") + + # Mock active branch + active_branch = MagicMock() + active_branch.name = "main" + + # Mock tracking branch + tracking_branch = MagicMock() + tracking_branch.name = "origin/main" + active_branch.tracking_branch = MagicMock(return_value=tracking_branch) + + repo.active_branch = active_branch + + # Mock remotes + remote_obj = MagicMock() + remote_obj.fetch = MagicMock() + remote_obj.url = "https://github.com/owner/repo.git" # Set a non-test URL + + # Mock refs for the remote + ref_obj = MagicMock() + commit_obj = MagicMock() + commit_obj.hexsha = "abc123" + ref_obj.commit = commit_obj + + remote_obj.refs = {"main": ref_obj} + repo.remotes = {"origin": remote_obj} + repo.refs = {"origin/main": ref_obj} + + # Mock git.rev_parse + repo.git = MagicMock() + repo.git.rev_parse = MagicMock(return_value="abc123") + + # Ensure repo.commit returns a commit-like object with the expected hexsha + # and no parents so that comparisons in verify_upstream_unchanged succeed. + commit_obj.iter_parents = MagicMock(return_value=[]) + repo.commit = MagicMock(return_value=commit_obj) + + return repo + + +@pytest.fixture +def git_project(tmp_path: Path) -> GitProject: + """Create a GitProject instance for testing.""" + return semantic_release.gitproject.GitProject(directory=tmp_path) + + +@pytest.fixture +def mock_gitproject( + git_project: GitProject, mock_repo: RepoMock +) -> Generator[GitProject, None, None]: + """Patch the GitProject to use the mock Repo.""" + module_path = semantic_release.gitproject.__name__ + with patch(f"{module_path}.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + yield git_project + + +def test_verify_upstream_unchanged_success( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged succeeds when upstream has not changed.""" + # Should not raise an exception + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + # Verify fetch was called + mock_repo.remotes["origin"].fetch.assert_called_once() + # Verify rev_parse was called for HEAD + mock_repo.git.rev_parse.assert_called_once_with("HEAD") + + +def test_verify_upstream_unchanged_fails_when_changed( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged raises error when upstream has changed.""" + # Mock git operations with different SHAs + mock_repo.git.rev_parse = MagicMock( + return_value="def456" # Different from upstream + ) + + # Ensure repo.commit returns a commit-like object with the different hexsha + changed_commit = MagicMock() + changed_commit.hexsha = "def456" + changed_commit.iter_parents = MagicMock(return_value=[]) + mock_repo.commit = MagicMock(return_value=changed_commit) + + with pytest.raises( + UpstreamBranchChangedError, match=r"Upstream branch .* has changed" + ): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_noop( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged does nothing in noop mode.""" + # Should not raise an exception and should not call git operations + mock_gitproject.verify_upstream_unchanged(noop=True) + + # Verify Repo was not instantiated at all in noop mode + mock_repo.assert_not_called() + + +def test_verify_upstream_unchanged_no_remote( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged raises error when no remote exists.""" + # Mock no remote + mock_repo.remotes = {} + # Simulate no tracking branch + mock_repo.active_branch.tracking_branch = MagicMock(return_value=None) + + # Should raise UnknownUpstreamBranchError + with pytest.raises( + UnknownUpstreamBranchError, + match="No remote found; cannot verify upstream state!", + ): + mock_gitproject.verify_upstream_unchanged( + local_ref="HEAD", upstream_ref="upstream", noop=False + ) + + +def test_verify_upstream_unchanged_no_upstream_ref( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged raises error when no upstream ref exists.""" + # Simulate no tracking branch + mock_repo.active_branch.tracking_branch = MagicMock(return_value=None) + mock_repo.refs = {} # No refs available + + # Should raise UnknownUpstreamBranchError + with pytest.raises(UnknownUpstreamBranchError, match="No upstream branch found"): + mock_gitproject.verify_upstream_unchanged( + local_ref="HEAD", upstream_ref="origin", noop=False + ) + + +def test_verify_upstream_unchanged_detached_head( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged raises error in detached HEAD state.""" + # Simulate detached HEAD by having active_branch raise TypeError + # This is what GitPython does when in a detached HEAD state + type(mock_repo).active_branch = PropertyMock(side_effect=TypeError("detached HEAD")) + + # Should raise DetachedHeadGitError + with pytest.raises(DetachedHeadGitError, match="detached HEAD state"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_fetch_fails( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged raises GitFetchError when fetch fails.""" + # Mock fetch to raise an error + mock_repo.remotes["origin"].fetch = MagicMock( + side_effect=GitCommandError("fetch", "error") + ) + + with pytest.raises(GitFetchError, match="Failed to fetch from remote"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_upstream_sha_fails( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged raises error when upstream SHA cannot be determined.""" + # Mock refs to raise AttributeError (simulating missing branch) + mock_repo.remotes["origin"].refs = MagicMock() + mock_repo.remotes["origin"].refs.__getitem__ = MagicMock( + side_effect=AttributeError("No such ref") + ) + + with pytest.raises(GitFetchError, match="Unable to determine upstream branch SHA"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_local_ref_sha_fails( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged raises error when local ref SHA cannot be determined.""" + # Mock git operations - rev_parse fails + mock_repo.git.rev_parse = MagicMock( + side_effect=GitCommandError("rev-parse", "error") + ) + + with pytest.raises( + LocalGitError, + match="Unable to determine the SHA for local ref", + ): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_with_custom_ref( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged works with a custom ref like HEAD~1.""" + # Should not raise an exception + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD~1", noop=False) + + # Verify rev_parse was called with custom ref + mock_repo.git.rev_parse.assert_called_once_with("HEAD~1") + + +def test_verify_upstream_unchanged_with_remote_url( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged uses remote_url when provided.""" + remote_url = "https://token:x-oauth-basic@github.com/owner/repo.git" + + # Should not raise an exception + mock_gitproject.verify_upstream_unchanged( + local_ref="HEAD", remote_url=remote_url, noop=False + ) + + # Verify git.fetch was called with the remote_url and proper refspec instead of remote_ref_obj.fetch() + mock_repo.git.fetch.assert_called_once_with( + remote_url, "refs/heads/main:refs/remotes/origin/main" + ) + # Verify that remote_ref_obj.fetch() was NOT called + mock_repo.remotes["origin"].fetch.assert_not_called() + + +def test_is_shallow_clone_true(mock_gitproject: GitProject, tmp_path: Path) -> None: + """Test is_shallow_clone returns True when shallow file exists.""" + # Create a shallow file + shallow_file = tmp_path / ".git" / "shallow" + shallow_file.parent.mkdir(parents=True, exist_ok=True) + shallow_file.touch() + + assert mock_gitproject.is_shallow_clone() is True + + +def test_is_shallow_clone_false(mock_gitproject: GitProject, tmp_path: Path) -> None: + """Test is_shallow_clone returns False when shallow file does not exist.""" + # Ensure shallow file does not exist + shallow_file = tmp_path / ".git" / "shallow" + if shallow_file.exists(): + shallow_file.unlink() + + assert mock_gitproject.is_shallow_clone() is False + + +def test_git_unshallow_success( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow successfully unshallows a repository.""" + mock_gitproject.git_unshallow(noop=False) + mock_repo.git.fetch.assert_called_once_with("--unshallow") + + +def test_git_unshallow_noop(mock_gitproject: GitProject, mock_repo: RepoMock) -> None: + """Test git_unshallow in noop mode does not execute the command.""" + mock_gitproject.git_unshallow(noop=True) + mock_repo.git.fetch.assert_not_called() + + +def test_git_unshallow_already_complete( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow handles already-complete repository gracefully.""" + # Simulate error from git when repo is already complete + error_msg = "fatal: --unshallow on a complete repository does not make sense" + mock_repo.git.fetch.side_effect = GitCommandError( + "fetch", status=128, stderr=error_msg + ) + + # Should not raise an exception + mock_gitproject.git_unshallow(noop=False) + + +def test_git_unshallow_other_error( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow raises exception for other errors.""" + # Simulate a different error + error_msg = "fatal: some other error" + mock_repo.git.fetch.side_effect = GitCommandError( + "fetch", status=128, stderr=error_msg + ) + + # Should raise the exception + with pytest.raises(GitCommandError): + mock_gitproject.git_unshallow(noop=False) diff --git a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py index 8280e95bb..ddca3dbf6 100644 --- a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py +++ b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py @@ -215,6 +215,59 @@ def test_pattern_declaration_is_version_replacer(): """ ), ), + ( + "Using default number format for c-macro style definition (see #1348)", + f"{test_file}:APP_VERSION:{VersionStampType.NUMBER_FORMAT.value}", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses colon separator with double quotes + dedent( + """\ + #ifndef VERSION_H + #define VERSION_H + + #define APP_VERSION "0.0.0" + + #endif // VERSION_H + """ + ), + dedent( + f"""\ + #ifndef VERSION_H + #define VERSION_H + + #define APP_VERSION "{next_version}" + + #endif // VERSION_H + """ + ), + ), + ( + "Using default tag format for c-macro style definition (see #1348)", + f"{test_file}:APP_VERSION:{VersionStampType.TAG_FORMAT.value}", + lazy_fixture(default_tag_format_str.__name__), + # Uses colon separator with double quotes + dedent( + """\ + #ifndef VERSION_H + #define VERSION_H + + #define APP_VERSION "v0.0.0" + + #endif // VERSION_H + """ + ), + dedent( + f"""\ + #ifndef VERSION_H + #define VERSION_H + + #define APP_VERSION "v{next_version}" + + #endif // VERSION_H + """ + ), + ), ] ], ) diff --git a/tests/unit/semantic_release/version/test_algorithm.py b/tests/unit/semantic_release/version/test_algorithm.py index a7fded2ef..f470e78ce 100644 --- a/tests/unit/semantic_release/version/test_algorithm.py +++ b/tests/unit/semantic_release/version/test_algorithm.py @@ -155,56 +155,49 @@ def test_sorted_repo_tags_and_versions(tags: list[str], sorted_tags: list[str]): @pytest.mark.parametrize( "tag_format, invalid_tags, valid_tags", [ - ( - "v{version}", - ("test-v1.1.0", "v1.1.0-test-test"), - [ - "v1.0.0-rc.1", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - "v{version}", - ("0.3", "0.4"), - [ - "v1.0.0-rc.1", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - r"(\w+--)?v{version}", - ("v1.1.0-test-test", "test_v1.1.0"), - [ - "v1.0.0-rc.1", - "test--v1.1.0", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - r"(?Pfeature|fix)/v{version}--(?Pdev|stg|prod)", - ("v1.1.0--test", "test_v1.1.0", "docs/v1.2.0--dev"), - [ - "feature/v1.0.0-rc.1--dev", - "fix/v1.1.0--stg", - "feature/v1.0.0-beta.2--stg", - "fix/v1.0.0-beta.11--dev", - "fix/v1.0.0-alpha.1--dev", - "feature/v1.0.0-alpha.beta.1--dev", - "feature/v1.0.0--prod", - ], - ), + pytest.param( + tag_format, + invalid_tags, + valid_tags, + id=test_id, + ) + for test_id, tag_format, invalid_tags, valid_tags in [ + ( + "traditional-v-prefixed-versions", + "v{version}", + ( + "0.3", # no v-prefix + "test-v1.1.0", # extra prefix + "v1.1.0-test-test", # bad suffix + ), + [ + "v1.0.0-rc.1", + "v1.0.0-beta.2", + "v1.0.0-beta.11", + "v1.0.0-alpha.1", + "v1.0.0-alpha.beta.1", + "v1.0.0", + ], + ), + ( + "monorepo-style-versions", + "pkg1-v{version}", + ( + "0.3", # no pkg or version prefix + "v1.1.0", # no pkg prefix + "pkg1-v1.1.0-test-test", # bad suffix + "pkg2-v1.1.0", # wrong package prefix + ), + [ + "pkg1-v1.0.0-rc.1", + "pkg1-v1.0.0-beta.2", + "pkg1-v1.0.0-beta.11", + "pkg1-v1.0.0-alpha.1", + "pkg1-v1.0.0-alpha.beta.1", + "pkg1-v1.0.0", + ], + ), + ] ], ) def test_tags_and_versions_ignores_invalid_tags_as_versions( diff --git a/tests/util.py b/tests/util.py index 8d529a769..d97e04845 100644 --- a/tests/util.py +++ b/tests/util.py @@ -58,26 +58,21 @@ def get_func_qual_name(func: Callable) -> str: def assert_exit_code( exit_code: int, result: ClickInvokeResult, cli_cmd: list[str] ) -> bool: - if result.exit_code != exit_code: - raise AssertionError( - str.join( - os.linesep, - [ - f"{result.exit_code} != {exit_code} (actual != expected)", - "", - # Explain what command failed - "Unexpected exit code from command:", - indent(f"'{str.join(' ', cli_cmd)}'", " " * 2), - "", - # Add indentation to each line for stdout & stderr - "stdout:", - indent(result.stdout, " " * 2) if result.stdout.strip() else "", - "stderr:", - indent(result.stderr, " " * 2) if result.stderr.strip() else "", - ], - ) + if result.exit_code == exit_code: + return True + + raise AssertionError( + str.join( + os.linesep, + [ + f"{result.exit_code} != {exit_code} (actual != expected)", + "", + # Explain what command failed + "Unexpected exit code from command:", + indent(f"'{str.join(' ', cli_cmd)}'", " " * 2), + ], ) - return True + ) def assert_successful_exit_code(result: ClickInvokeResult, cli_cmd: list[str]) -> bool: