diff --git a/.github/scripts/get_version_info.py b/.github/scripts/get_version_info.py new file mode 100644 index 00000000..27508645 --- /dev/null +++ b/.github/scripts/get_version_info.py @@ -0,0 +1,98 @@ +import os +import re +import subprocess +from collections import defaultdict + +# Regex patterns for Conventional Commits +feat_pattern = re.compile(r"^feat(\([^)]+\))?:", re.IGNORECASE) +feat_breaking_pattern = re.compile(r"^feat!|^feat!\([^)]*\):", re.IGNORECASE) +fix_pattern = re.compile(r"^fix(\([^)]+\))?:", re.IGNORECASE) + + +def get_version_bump_from_commits(last_tag: str | None) -> str | None: + """ + Determines the version bump type ('major', 'minor', 'patch') from commit messages. + """ + try: + if last_tag: + commits = subprocess.check_output( + ["git", "log", f"{last_tag}..HEAD", "--merges", "--pretty=format:%B"], + text=True, + ).strip() + else: + commits = subprocess.check_output( + ["git", "log", "--merges", "--pretty=format:%B"], text=True + ).strip() + except Exception as exc: + print(f"Error getting commits: {exc}") + commits = "" + + if not commits: + return None + + commit_lines = [c.strip() for c in commits.splitlines() if c.strip()] + bump_commits = defaultdict(list) + + for c in commit_lines: + if feat_breaking_pattern.match(c): + bump_commits["major"].append(c) + elif feat_pattern.match(c): + bump_commits["minor"].append(c) + elif fix_pattern.match(c): + bump_commits["patch"].append(c) + + if bump_commits["major"]: + bump_type = "major" + elif bump_commits["minor"]: + bump_type = "minor" + elif bump_commits["patch"]: + bump_type = "patch" + else: + bump_type = None + + print(f"\nVersion bump type from commits: {bump_type}") + for bt in ["major", "minor", "patch"]: + if bump_commits[bt]: + print(f"\n{bt.upper()} commits ({len(bump_commits[bt])}):") + for commit in bump_commits[bt]: + print(f" {commit}") + + return bump_type + + +def main(): + """ + Main function to determine version bump part and current version. + """ + # Determine version part + part = os.environ.get("VERSION_PART_INPUT") + if not part: + try: + last_tag = ( + subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"]) + .decode() + .strip() + ) + except subprocess.CalledProcessError as exc: + print( + f"Could not get last tag, probably because there are no tags yet. Error: {exc}" + ) + last_tag = None + print(f"Last tag: {last_tag}") + part = get_version_bump_from_commits(last_tag) + else: + print(f"Got version bump part from input: {part}") + + # Set outputs + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + if part: + f.write(f"part={part}\n") + f.write("bump_needed=true\n") + print(f"Version bump part: {part} and bump_needed=true") + else: + f.write("bump_needed=false\n") + print("No version bump needed.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/increment_version.py b/.github/scripts/increment_version.py new file mode 100644 index 00000000..ba173a67 --- /dev/null +++ b/.github/scripts/increment_version.py @@ -0,0 +1,69 @@ +import os +import re + + +def increment_version(version, part): + if "rc" in version: + if ".rc" in version: + base_version, rc_part = version.split(".rc") + else: + base_version, rc_part = version.split("rc") + major, minor, patch = map(int, base_version.split(".")) + rc_num = int(rc_part) + else: + major, minor, patch = map(int, version.split(".")) + rc_num = None + + if part == "major": + major += 1 + minor = 0 + patch = 0 + rc_num = None + elif part == "minor": + minor += 1 + patch = 0 + rc_num = None + elif part == "patch": + patch += 1 + rc_num = None + elif part == "rc": + if rc_num is not None: + rc_num += 1 + else: + rc_num = 0 + elif part == "release": + rc_num = None + else: + raise ValueError( + "Part must be one of 'major', 'minor', 'patch', 'rc', or 'release'" + ) + + return ( + f"{major}.{minor}.{patch}" + if rc_num is None + else f"{major}.{minor}.{patch}.rc{rc_num}" + ) + + +def main(): + part = os.environ["PART"] + # Get current version from pyproject.toml + with open("pyproject.toml", "r") as file: + content = file.read() + current_version_match = re.search( + r'version\s*=\s*"(\d+\.\d+\.\d+(?:\.?rc\d+)?)', content + ) + if not current_version_match: + raise RuntimeError("Could not find version in pyproject.toml") + current_version = current_version_match.group(1) + new_version = increment_version(current_version, part) + + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"current_version={current_version}\n") + f.write(f"new_version={new_version}\n") + + print(f"new_version: {new_version}") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/update_files.py b/.github/scripts/update_files.py new file mode 100644 index 00000000..a7659799 --- /dev/null +++ b/.github/scripts/update_files.py @@ -0,0 +1,33 @@ +import os +import re + + +def update_file(file_path, old_version, new_version): + with open(file_path, "r") as file: + content = file.read() + + # Use word boundaries to avoid replacing parts of other strings + old_version_pattern = r"\b" + re.escape(old_version) + r"\b" + content = re.sub(old_version_pattern, new_version, content) + + with open(file_path, "w") as file: + file.write(content) + + +def main(): + files_to_update = [ + "pyproject.toml", + "src/fabric_cli/__init__.py", + "src/fabric_cli/core/fab_constant.py", + ] + + old_version = os.environ["OLD_VERSION"] + new_version = os.environ["NEW_VERSION"] + + for file_path in files_to_update: + update_file(file_path, old_version, new_version) + print(f"Updated {file_path} from {old_version} to {new_version}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 7179ead3..e80bf4c3 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -13,6 +13,7 @@ on: - ready_for_review permissions: + issues: write pull-requests: write jobs: diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 00000000..8954ca86 --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,128 @@ +name: Bump Version test + +on: + workflow_dispatch: + inputs: + version_part: + description: 'The part of the version to bump' + required: false + type: choice + default: 'patch' + options: + - major + - minor + - patch + - rc + - release + + dry_run: + description: 'Run without creating a commit or PR to test version calculation.' + required: false + type: boolean + default: false + + # NOTE: The 'push' trigger is for testing in a fork. + # Please remove it before creating a pull request to the main repository. + push: + branches: + - test-version-bump-workflow + + +jobs: + bump-version: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Get version info + id: version_info + run: python .github/scripts/get_version_info.py + env: + VERSION_PART_INPUT: ${{ github.event.inputs.version_part }} + + - name: Prepare Inputs + id: prep_inputs + run: | + # For push events, github.event.inputs is empty. We need to provide default values. + # For workflow_dispatch, we'll use the provided inputs or the defaults. + VERSION_PART="${{ github.event.inputs.version_part || 'patch' }}" + DRY_RUN="${{ github.event.inputs.dry_run || 'false' }}" + echo "version_part=${VERSION_PART}" >> $GITHUB_OUTPUT + echo "dry_run=${DRY_RUN}" >> $GITHUB_OUTPUT + + - name: Increment version + if: steps.version_info.outputs.bump_needed == 'true' + id: increment_version + run: python .github/scripts/increment_version.py + env: + PART: ${{ steps.version_info.outputs.part }} + + + - name: Update files + # if: github.event.inputs.dry_run == 'false' && steps.version_info.outputs.bump_needed == 'true' + if: steps.prep_inputs.outputs.dry_run == 'false' && steps.version_info.outputs.bump_needed == 'true' + id: update_files + run: python .github/scripts/update_files.py + env: + OLD_VERSION: ${{ steps.increment_version.outputs.current_version }} + NEW_VERSION: ${{ steps.increment_version.outputs.new_version }} + + - name: Commit and Push Changes + if: steps.prep_inputs.outputs.dry_run == 'false' && steps.version_info.outputs.bump_needed == 'true' + id: commit_and_push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -b chore/version-bump-${{ steps.increment_version.outputs.new_version }} + git add pyproject.toml src/fabric_cli/__init__.py src/fabric_cli/core/fab_constant.py + git commit -m "chore(version): bump '${{ steps.version_info.outputs.part }}' version to ${{ steps.increment_version.outputs.new_version }}" + git push --set-upstream origin chore/version-bump-${{ steps.increment_version.outputs.new_version }} + + - name: Create Pull Request + # if: github.event.inputs.dry_run == 'false' && steps.version_info.outputs.bump_needed == 'true' + if: steps.prep_inputs.outputs.dry_run == 'false' && steps.version_info.outputs.bump_needed == 'true' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const new_version = "${{ steps.increment_version.outputs.new_version }}"; + const branch = `chore/version-bump-${new_version}`; + + await github.rest.pulls.create({ + owner, + repo, + title: `chore(version): bump version to ${new_version}`, + head: branch, + base: 'main', + body: `This PR bumps the version to ${new_version}`, + draft: true + }); + + - name: Tag new version + if: false || steps.prep_inputs.outputs.dry_run == 'false' && steps.version_info.outputs.bump_needed == 'true' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const new_version = "v${{ steps.increment_version.outputs.new_version }}"; + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${new_version}`, + sha: context.sha + }); \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000..c025af29 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,103 @@ +# Starter pipeline +# Start with a minimal pipeline that you can customize to build and deploy your code. +# Add steps that build, run tests, deploy, and more: +# https://aka.ms/yaml + +trigger: none # This pipeline is manually triggered + +parameters: +- name: version_part + displayName: 'The part of the version to bump' + type: string + default: 'patch' + values: + - major + - minor + - patch + - rc + - release +- name: dry_run + displayName: 'Run without creating a commit or PR to test version calculation.' + type: boolean + default: false + +pool: + vmImage: 'ubuntu-latest' + +variables: + # For push events, github.event.inputs is empty. We need to provide default values. + # For workflow_dispatch, we'll use the provided inputs or the defaults. + VERSION_PART: ${{ parameters.version_part }} + DRY_RUN: ${{ parameters.dry_run }} + +jobs: +- job: BumpVersion + displayName: 'Bump Version' + steps: + - checkout: self + persistCredentials: true # Needed to push changes back to the repo + fetchDepth: 0 # Equivalent to fetch-depth: 0 in GitHub Actions + + - task: UsePythonVersion@0 + displayName: 'Set up Python 3.12' + inputs: + versionSpec: '3.12' + + - script: python .github/scripts/get_version_info.py + displayName: 'Get version info' + name: version_info + env: + VERSION_PART_INPUT: $(VERSION_PART) + + - script: python .github/scripts/increment_version.py + displayName: 'Increment version' + name: increment_version + condition: and(succeeded(), eq(variables['version_info.bump_needed'], 'true')) + env: + PART: $(version_info.part) + + - script: python .github/scripts/update_files.py + displayName: 'Update files' + name: update_files + condition: and(succeeded(), eq(variables.DRY_RUN, 'false'), eq(variables['version_info.bump_needed'], 'true')) + env: + OLD_VERSION: $(increment_version.current_version) + NEW_VERSION: $(increment_version.new_version) + + - bash: | + git config --global user.name "fabric-cli-bot" + git config --global user.email "fabric-cli-bot@users.noreply.github.com" + BRANCH_NAME="chore/version-bump-$(increment_version.new_version)" + git checkout -b $BRANCH_NAME + git add pyproject.toml src/fabric_cli/__init__.py src/fabric_cli/core/fab_constant.py + git commit -m "chore(version): bump '$(version_info.part)' version to $(increment_version.new_version)" + git push --set-upstream origin $BRANCH_NAME + echo "##vso[task.setvariable variable=branchName;isOutput=true]$BRANCH_NAME" + displayName: 'Commit and Push Changes' + name: commit_and_push + condition: and(succeeded(), eq(variables.DRY_RUN, 'false'), eq(variables['version_info.bump_needed'], 'true')) + env: + # The System.AccessToken is a special variable that provides a token for the build service + # Ensure the "Project Collection Build Service" has "Contribute" and "Create branch" permissions on the repository + GIT_HTTP_USER_AGENT: "git/2.22.0 (linux)" + GIT_AUTHORIZATION_HEADER: "Authorization: Bearer $(System.AccessToken)" + + + - bash: | + echo "Creating PR for branch $(commit_and_push.branchName)" + curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $(System.AccessToken)" \ + -d '{ + "sourceRefName": "refs/heads/$(commit_and_push.branchName)", + "targetRefName": "refs/heads/main", + "title": "chore(version): bump version to $(increment_version.new_version)", + "description": "This PR bumps the version to $(increment_version.new_version)", + "isDraft": true + }' \ + "$(System.TeamFoundationCollectionUri)$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.ID)/pullrequests?api-version=6.0" + displayName: 'Create Pull Request' + condition: and(succeeded(), eq(variables.DRY_RUN, 'false'), eq(variables['version_info.bump_needed'], 'true')) + env: + # Ensure the "Project Collection Build Service" has "Contribute to pull requests" permission + SYSTEM_ACCESSTOKEN: $(System.AccessToken) \ No newline at end of file