Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tag release branch #18413

Merged
merged 5 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ddev/changelog.d/18413.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add command to tag the Agent release branch. It supports both RC and final tags, see `ddev release branch tag --help` for more details.
2 changes: 2 additions & 0 deletions ddev/src/ddev/cli/release/branch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import click

from ddev.cli.release.branch.create import create
from ddev.cli.release.branch.tag import tag


@click.group(short_help='Manage release branches')
Expand All @@ -14,3 +15,4 @@ def branch():


branch.add_command(create)
branch.add_command(tag)
63 changes: 63 additions & 0 deletions ddev/src/ddev/cli/release/branch/tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import re

import click
from packaging.version import Version

from .create import BRANCH_NAME_REGEX


@click.command
@click.option(
'--final/--rc',
default=False,
show_default=True,
help="Whether we're tagging the final release or a release candidate (rc).",
)
@click.pass_obj
def tag(app, final):
"""
Tag the release branch either as release candidate or final release.
"""
branch_name = app.repo.git.current_branch()
release_branch = BRANCH_NAME_REGEX.match(branch_name)
if release_branch is None:
app.abort(
f'Invalid branch name: {branch_name}. Branch name must match the pattern {BRANCH_NAME_REGEX.pattern}.'
)
click.echo(app.repo.git.pull(branch_name))
click.echo(app.repo.git.fetch_tags())
major_minor_version = branch_name.replace('.x', '')
this_release_tags = sorted(
(
Version(t)
for t in set(app.repo.git.tags(glob_pattern=major_minor_version + '.*'))
# We take 'major.minor.x' as the branch name pattern and replace 'x' with 'patch-rc.number'.
if re.match(BRANCH_NAME_REGEX.pattern.replace('x', r'\d+\-rc\.\d+'), t)
),
reverse=True,
)
patch_version, next_rc_guess = (
# The first item in this_release_tags is the latest tag parsed as a Version object.
(this_release_tags[0].micro, this_release_tags[0].pre[1] + 1)
if this_release_tags
else (0, 1)
)
if final:
new_tag = f'{major_minor_version}.{patch_version}'
else:
next_rc = click.prompt(
'Which RC number are we tagging? (hit ENTER to accept suggestion)', type=int, default=next_rc_guess
iliakur marked this conversation as resolved.
Show resolved Hide resolved
)
new_tag = f'{major_minor_version}.{patch_version}-rc.{next_rc}'
if Version(new_tag) in this_release_tags:
app.abort(f'Tag {new_tag} already exists. Switch to git to overwrite it.')
if next_rc < next_rc_guess:
click.secho('!!! WARNING !!!')
if not click.confirm(
'You are about to create an RC with a number less than the latest RC number (12). Are you sure?'
iliakur marked this conversation as resolved.
Show resolved Hide resolved
):
app.abort('Did not get confirmation, aborting. Did not create or push the tag.')
if not click.confirm(f'Create and push this tag: {new_tag}?'):
app.abort('Did not get confirmation, aborting. Did not create or push the tag.')
click.echo(app.repo.git.tag(new_tag, message=new_tag))
click.echo(app.repo.git.push(new_tag))
17 changes: 16 additions & 1 deletion ddev/src/ddev/utils/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,26 @@ def latest_commit(self) -> GitCommit:
sha, subject = self.capture('log', '-1', '--format=%H%n%s').splitlines()
return GitCommit(sha, subject=subject)

def pull(self, ref):
return self.capture('pull', 'origin', ref)

def push(self, ref):
return self.capture('push', 'origin', ref)

def tag(self, value, message=None):
"""
Create a tag with an optional message.
"""
cmd = ['tag', value]
if message is not None:
cmd.extend(['--message', value])
return self.capture(*cmd)

def tags(self, glob_pattern=None) -> list[str]:
"""
List the repo's tags and sort them.

If provided, `glob_pattern` filters tags just like the pattern argument to `git tag --list`.
If not None, we pass `glob_pattern` as the pattern argument to `git tag --list`.
"""

cmd = ['tag', '--list']
Expand Down
191 changes: 191 additions & 0 deletions ddev/tests/cli/release/branch/test_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# (C) Datadog, Inc. 2024-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from unittest.mock import call as c

import pytest

from ddev.utils.git import GitRepository

NO_CONFIRMATION_SO_ABORT = 'Did not get confirmation, aborting. Did not create or push the tag.'


@pytest.fixture
def basic_git(mocker):
mock_git = mocker.create_autospec(GitRepository)
# We're patching the constructor (__new__) method of GitRepository class.
# That's why we need a function that returns the mock.
mocker.patch('ddev.repo.core.GitRepository', lambda _: mock_git)
return mock_git


@pytest.fixture
def git(basic_git):
basic_git.current_branch.return_value = '7.56.x'
basic_git.tags.return_value = [
# interesting phenomena:
# - skipping RCs
# - rc 11 naively would be sorted as less than rc 2
# - an rc tag from dbm
# '7.56.0',
iliakur marked this conversation as resolved.
Show resolved Hide resolved
'7.56.0-rc.1',
'7.56.0-rc.1-dbm-agent-jobs',
'7.56.0-rc.11',
'7.56.0-rc.2',
'7.56.0-rc.5',
'7.56.0-rc.6',
'7.56.0-rc.7',
'7.56.0-rc.8',
]
yield basic_git
assert basic_git.method_calls[:4] == [
c.current_branch(),
c.pull('7.56.x'),
c.fetch_tags(),
c.tags(glob_pattern='7.56.*'),
]


def _assert_tag_pushed(git, result, tag):
assert result.exit_code == 0, result.output
assert git.method_calls[-2:] == [
c.tag(tag, message=tag),
c.push(tag),
]
assert f'Create and push this tag: {tag}?' in result.output


def test_wrong_branch(ddev, basic_git):
"""
Given a branch that doesn't match the release branch pattern we should abort.
"""
name = 'foo'
basic_git.current_branch.return_value = name

result = ddev('release', 'branch', 'tag')

assert result.exit_code == 1, result.output
assert rf'Invalid branch name: {name}. Branch name must match the pattern ^\d+\.\d+\.x$' in result.output
assert basic_git.method_calls == [c.current_branch()]


def test_middle_of_release_next_rc(ddev, git):
"""
We're in the middle of a release, some RCs are already done. We want to create the next RC.
"""
result = ddev('release', 'branch', 'tag', input='\ny\n')

_assert_tag_pushed(git, result, '7.56.0-rc.12')
assert 'Which RC number are we tagging? (hit ENTER to accept suggestion) [12]' in result.output
iliakur marked this conversation as resolved.
Show resolved Hide resolved


@pytest.mark.parametrize(
'no_confirm',
[
pytest.param('n', id='explicit abort'),
pytest.param('', id='abort by default'),
pytest.param('x', id='abort on any other input'),
],
)
@pytest.mark.parametrize('rc_num', ['3', '10'])
def test_do_not_confirm_non_sequential_rc(ddev, git, rc_num, no_confirm):
"""
We're in the middle of a release, some RCs are already done. User wants to create the next RC.

However the user asks to create an RC that's less than the latest RC number.
This is unusual, so we give a warning and ask user to confirm. If they don't, we abort.

Important: we are not overwriting an existing RC tag.
"""

result = ddev('release', 'branch', 'tag', input=f'{rc_num}\n{no_confirm}\n')

assert result.exit_code == 1, result.output
assert 'Which RC number are we tagging? (hit ENTER to accept suggestion) [12]' in result.output
iliakur marked this conversation as resolved.
Show resolved Hide resolved
assert (
'!!! WARNING !!!\n'
'You are about to create an RC with a number less than the latest RC number (12). Are you sure? [y/N]'
) in result.output
assert NO_CONFIRMATION_SO_ABORT in result.output


@pytest.mark.parametrize('rc_num', ['3', '10'])
def test_confirm_non_sequential_rc(ddev, git, rc_num):
"""
We're in the middle of a release, some RCs are already done. User wants to create the next RC.

However the user asks to create an RC that's less than the latest RC number.
This is unusual, so we give a warning and ask user to confirm. If they do, we create the tag.

Important: we are not overwriting an existing RC tag.
"""
result = ddev('release', 'branch', 'tag', input=f'{rc_num}\ny\ny\n')

assert 'Which RC number are we tagging? (hit ENTER to accept suggestion) [12]' in result.output
iliakur marked this conversation as resolved.
Show resolved Hide resolved
assert (
'!!! WARNING !!!\n'
'You are about to create an RC with a number less than the latest RC number (12). Are you sure? [y/N]'
) in result.output
_assert_tag_pushed(git, result, f'7.56.0-rc.{rc_num}')


@pytest.mark.parametrize('rc_num', ['1', '5', '11'])
def test_abort_if_rc_tag_exists(ddev, git, rc_num):
"""
We're in the middle of a release, some RCs are already done. User wants to create the next RC.

However the user asks to create an RC for which we already have a tag. This requires special git flags to clobber
the local and remote tags. To keep our logic simple we give up here and leave a hint how the user can proceed.
"""

result = ddev('release', 'branch', 'tag', input=f'{rc_num}\ny\n')

assert result.exit_code == 1, result.output
assert 'Which RC number are we tagging? (hit ENTER to accept suggestion) [12]' in result.output
iliakur marked this conversation as resolved.
Show resolved Hide resolved
assert (f'Tag 7.56.0-rc.{rc_num} already exists. Switch to git to overwrite it.') in result.output


@pytest.mark.parametrize(
'no_confirm',
[
pytest.param('n', id='explicit abort'),
pytest.param('', id='abort by default'),
pytest.param('x', id='abort on any other input'),
],
)
def test_abort_valid_rc(ddev, git, no_confirm):
"""
The RC is fine but we don't confirm in the end, so we abort.
"""
git.tags.return_value = []

result = ddev('release', 'branch', 'tag', input='\n{no_confirm}\n')

assert 'Which RC number are we tagging? (hit ENTER to accept suggestion) [1]' in result.output
iliakur marked this conversation as resolved.
Show resolved Hide resolved
assert result.exit_code == 1, result.output
assert NO_CONFIRMATION_SO_ABORT in result.output


def test_first_rc(ddev, git):
"""
First RC for a new release.
add some more examples?
should be ok to specify a number other than 1
"""
git.tags.return_value = []

result = ddev('release', 'branch', 'tag', input='\ny\n')

_assert_tag_pushed(git, result, '7.56.0-rc.1')
assert 'Which RC number are we tagging? (hit ENTER to accept suggestion) [1]' in result.output
iliakur marked this conversation as resolved.
Show resolved Hide resolved


def test_final(ddev, git):
"""
Create final release tag.

We should handle the case of bugfix releases here too
"""
result = ddev('release', 'branch', 'tag', '--final', input='y\n')

_assert_tag_pushed(git, result, '7.56.0')
Loading