Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
15 changes: 1 addition & 14 deletions .github/workflows/build-tag.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,7 @@ jobs:
run: uv sync --frozen --all-packages --group dev
- name: Verify tag matches a stable VERSION
if: startsWith(github.ref, 'refs/tags/v')
run: |
uv run python -c "
import os
from importlib.metadata import version
tag = os.environ['GITHUB_REF'].removeprefix('refs/tags/v')
package_version = version('dapr')
assert tag == package_version, (
f'Tag {os.environ[\"GITHUB_REF\"]!r} expects version={tag!r}, got {package_version!r}. '
'Bump VERSION before tagging, see RELEASE.md'
)
assert '.dev' not in package_version, (
f'Version {package_version!r} contains .dev. Release tags must point to a stable or rc version'
)
"
run: uv run python tools/check_release_version.py --tag "$GITHUB_REF"
- name: Run Autoformatter
run: |
uv run ruff check --fix && uv run ruff format
Expand Down
66 changes: 66 additions & 0 deletions .github/workflows/release-tag.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#
# Copyright 2026 The Dapr Authors
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

# Creates the release tag only after checking that VERSION on the release
# branch holds the version being released. A hand-pushed tag is only checked
# by dapr-python-release once the tag already exists, which is how v1.19.0rc2
# ended up tagged on a commit whose VERSION still said 1.19.0rc1.
name: dapr-python-release-tag

on:
workflow_dispatch:
inputs:
version:
description: 'Version to release, e.g. 1.19.0rc2. Must match VERSION on the selected release-X.Y branch.'
required: true
type: string

concurrency:
group: release-tag-${{ github.ref }}
cancel-in-progress: false

jobs:
tag:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Set up Python 3.10
uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Verify VERSION is ready to release
env:
EXPECTED_VERSION: ${{ inputs.version }}
run: |
python tools/check_release_version.py \
--expected "$EXPECTED_VERSION" \
--branch "$GITHUB_REF" \
--require-untagged
- name: Create and push tag
env:
# A tag pushed with GITHUB_TOKEN does not trigger other workflows,
# so the bot token is what lets this tag start dapr-python-release.
# It is only handed to this step, not persisted by checkout.
BOT_TOKEN: ${{ secrets.DAPR_BOT_TOKEN }}
run: |
release_tag="v$(tr -d '[:space:]' < VERSION)"
git tag "$release_tag" "$GITHUB_SHA"
git push "https://x-access-token:${BOT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
"refs/tags/$release_tag"
echo "Pushed $release_tag at $GITHUB_SHA; dapr-python-release publishes it." >> "$GITHUB_STEP_SUMMARY"
64 changes: 40 additions & 24 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,6 @@ The warning and the `flask_dapr` shim are kept through 1.21 and removed in
support keeps it alive until 1.21, so this window covers every user who could
still be migrating.

You can also create the tag via a **GitHub Release**, which auto-creates the tag and provides
a changelog UI.

## Overview

Releases follow a branching model where `main` is always the development trunk.
Expand Down Expand Up @@ -104,9 +101,10 @@ minor as `.dev` — once `release-X.Y` is forked, `main` advances from

## Remote convention

All commands below use `upstream` to refer to the **canonical Dapr repository**
All `git` commands below use `upstream` to refer to the **canonical Dapr repository**
(`https://github.com/dapr/python-sdk`), not your personal fork.
If your local remote is named differently, substitute accordingly.
If your local remote is named differently, substitute accordingly. The `gh` commands name the
repository with `--repo` instead.

## Scenario A — Fork a new release branch

Expand All @@ -131,19 +129,16 @@ changes the `VERSION` file from `X.Y.0.dev` → `X.Y.0rc0`.
Open a PR targeting `main` that changes `VERSION` from the previous dev version to
`X.Y.0.dev`.

### 4. Push the tag
### 4. Create the tag

Once the version bump PR on `release-X.Y` is merged, create and push the tag from the
**tip of `release-X.Y`**:
Once the version bump PR on `release-X.Y` is merged, run the `dapr-python-release-tag`
workflow on `release-X.Y` (see [Creating the tag](#creating-the-tag)):

```bash
git checkout release-X.Y
git pull upstream release-X.Y

git tag vX.Y.0rc0 && git push upstream vX.Y.0rc0
gh workflow run dapr-python-release-tag --repo dapr/python-sdk --ref release-X.Y -f version=X.Y.0rc0
```

The tag push triggers the `dapr-python-release` workflow which builds and uploads
The tag triggers the `dapr-python-release` workflow which builds and uploads
all packages to PyPI.

## Scenario B — Ship a new release candidate
Expand All @@ -154,15 +149,12 @@ Perform this when you want to publish `X.Y.0rcN` (N ≥ 1) from an existing `rel

Open a PR **targeting `release-X.Y`** that changes `VERSION` from `X.Y.0rc(N-1)` → `X.Y.0rcN`.

### 2. Push the tag
### 2. Create the tag

Once the PR is merged:

```bash
git checkout release-X.Y
git pull upstream release-X.Y

git tag vX.Y.0rcN && git push upstream vX.Y.0rcN
gh workflow run dapr-python-release-tag --repo dapr/python-sdk --ref release-X.Y -f version=X.Y.0rcN
```

## Scenario C — Ship the stable release (and patch releases)
Expand All @@ -175,19 +167,43 @@ initial `X.Y.0` or a patch release (`X.Y.1`, `X.Y.2`, …).
Open a PR **targeting `release-X.Y`** that drops the `rcN` suffix in `VERSION`:
`X.Y.ZrcN` → `X.Y.Z`.

### 2. Push the tag
### 2. Create the tag

Once the PR is merged:

```bash
git checkout release-X.Y
git pull upstream release-X.Y
gh workflow run dapr-python-release-tag --repo dapr/python-sdk --ref release-X.Y -f version=X.Y.Z
```

## Creating the tag

git tag vX.Y.Z && git push upstream vX.Y.Z
The `dapr-python-release-tag` workflow is the supported way to tag a release. Before it
creates `vX.Y.Z` at the tip of the selected branch, it runs `tools/check_release_version.py`,
which fails when:

- `VERSION` does not match the version you asked to release, which usually means the bump
PR has not been merged yet;
- the branch is not `release-X.Y`, or `VERSION` belongs to a different `X.Y` series;
- `VERSION` is a `.dev` version;
- a tag for `VERSION` already exists.

You can run the same check locally before triggering the workflow, from an up-to-date checkout
of `release-X.Y` with all tags fetched:

```bash
git checkout release-X.Y && git pull upstream release-X.Y && git fetch upstream --tags
python tools/check_release_version.py --expected X.Y.Z --branch release-X.Y --require-untagged
```

Alternatively, create a **GitHub Release** targeting `release-X.Y` with tag `vX.Y.Z` — this
creates the tag and triggers the publish workflow automatically.
The workflow runs the copy of itself found on the selected branch, so it only works on release
branches that contain `.github/workflows/release-tag.yaml` and `tools/check_release_version.py`.
Branches cut before these were added (`release-1.18` and older, and `release-1.19` until they are
backported) still tag by hand from the tip of the branch:
`git tag vX.Y.Z && git push upstream vX.Y.Z`.

A tag pushed by hand, or created through a GitHub Release, skips these checks. The publish
workflow still compares the tag to `VERSION` and refuses to upload on a mismatch. By then the
tag already exists, though, and has to be deleted and recreated once `VERSION` is fixed.

## Backporting changes

Expand Down
184 changes: 184 additions & 0 deletions tests/test_check_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-

"""
Copyright 2026 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Covers tools/check_release_version.py, the gate the release workflows run
before a tag is created and again before anything is published.
"""

import importlib.util
import subprocess
import tempfile
import unittest
from pathlib import Path
from types import ModuleType
from unittest import mock

REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT_PATH = REPO_ROOT / 'tools' / 'check_release_version.py'


def load_script() -> ModuleType:
"""Loads the script as a module; tools/ is not an importable package."""
module_spec = importlib.util.spec_from_file_location('check_release_version', SCRIPT_PATH)
assert module_spec is not None and module_spec.loader is not None
module = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(module)
return module


crv = load_script()


class ReleaseVersionFormatTests(unittest.TestCase):
def test_accepts_stable_and_rc_versions(self) -> None:
for version in ('1.19.0', '1.19.0rc0', '1.19.0rc12', '1.18.3', '2.0.1rc1'):
with self.subTest(version=version):
crv.check_release_version(version)

def test_rejects_dev_and_non_normalized_versions(self) -> None:
rejected_versions = (
'1.19.0.dev',
'1.19.0-rc1',
'1.19.0.rc1',
'1.19',
'v1.19.0',
'',
'1.19.0rc',
'1.19.0.post1',
'1.19.0rc1.dev0',
'01.19.0',
'1.19.0rc01',
'\u0661.19.0',
)
for version in rejected_versions:
with self.subTest(version=version):
with self.assertRaises(crv.ReleaseVersionError):
crv.check_release_version(version)


class ExpectedVersionTests(unittest.TestCase):
def test_matching_version_passes_with_or_without_tag_prefix(self) -> None:
crv.check_expected_version('1.19.0rc2', '1.19.0rc2')
crv.check_expected_version('1.19.0rc2', 'v1.19.0rc2')

def test_unbumped_version_fails(self) -> None:
with self.assertRaisesRegex(crv.ReleaseVersionError, 'Bump VERSION'):
crv.check_expected_version('1.19.0rc1', '1.19.0rc2')


class BranchTests(unittest.TestCase):
def test_version_in_branch_series_passes(self) -> None:
crv.check_branch('1.19.0rc2', 'release-1.19')
crv.check_branch('1.19.1', 'refs/heads/release-1.19')

def test_version_from_another_series_fails(self) -> None:
with self.assertRaisesRegex(crv.ReleaseVersionError, 'release-1.19'):
crv.check_branch('1.20.0rc0', 'release-1.19')

def test_non_release_branch_fails(self) -> None:
rejected_branches = (
'main',
'release-1',
'feature/release-1.19',
'release-1.19-hotfix',
'refs/tags/release-1.19',
'release-1.19.0',
)
for branch in rejected_branches:
with self.subTest(branch=branch):
with self.assertRaises(crv.ReleaseVersionError):
crv.check_branch('1.19.0', branch)


class TagTests(unittest.TestCase):
def test_matching_tag_passes(self) -> None:
crv.check_tag('1.19.0rc2', 'v1.19.0rc2')
crv.check_tag('1.19.0rc2', 'refs/tags/v1.19.0rc2')

def test_tag_ahead_of_version_fails(self) -> None:
with self.assertRaisesRegex(crv.ReleaseVersionError, 'Bump VERSION before tagging'):
crv.check_tag('1.19.0rc1', 'v1.19.0rc2')


class UntaggedTests(unittest.TestCase):
def test_new_version_passes(self) -> None:
crv.check_untagged('1.19.0rc2', {'v1.19.0rc0', 'v1.19.0rc1'})

def test_already_released_version_fails(self) -> None:
with self.assertRaisesRegex(crv.ReleaseVersionError, 'already exists'):
crv.check_untagged('1.19.0rc1', {'v1.19.0rc0', 'v1.19.0rc1'})


class ListGitTagsTests(unittest.TestCase):
def test_git_failure_raises_release_version_error(self) -> None:
git_error = subprocess.CalledProcessError(128, ['git'], stderr='not a git repository\n')
with mock.patch.object(crv.subprocess, 'run', side_effect=git_error):
with self.assertRaisesRegex(crv.ReleaseVersionError, 'not a git repository'):
crv.list_git_tags()

def test_missing_git_raises_release_version_error(self) -> None:
with mock.patch.object(crv.subprocess, 'run', side_effect=FileNotFoundError('git')):
with self.assertRaisesRegex(crv.ReleaseVersionError, 'Cannot list git tags'):
crv.list_git_tags()


class MainTests(unittest.TestCase):
def setUp(self) -> None:
temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
self.version_path = Path(temp_dir.name) / 'VERSION'
self.version_path.write_text('1.19.0rc1\n', encoding='utf-8')

def run_main(self, *args: str) -> int:
exit_code: int = crv.main(['--version-file', str(self.version_path), *args])
return exit_code

def test_passes_when_every_check_holds(self) -> None:
exit_code = self.run_main('--expected', '1.19.0rc1', '--branch', 'release-1.19')
self.assertEqual(exit_code, 0)

def test_fails_when_version_was_not_bumped(self) -> None:
with self.assertLogs(crv.logger, level='ERROR') as captured_logs:
exit_code = self.run_main('--expected', '1.19.0rc2', '--branch', 'release-1.19')
self.assertEqual(exit_code, 1)
self.assertIn('Bump VERSION', captured_logs.output[0])

def test_fails_when_version_is_already_tagged(self) -> None:
existing_tags = {'v1.19.0rc0', 'v1.19.0rc1'}
with mock.patch.object(crv, 'list_git_tags', return_value=existing_tags):
with self.assertLogs(crv.logger, level='ERROR') as captured_logs:
exit_code = self.run_main('--require-untagged')
self.assertEqual(exit_code, 1)
self.assertIn('already exists', captured_logs.output[0])

def test_passes_when_version_is_untagged(self) -> None:
with mock.patch.object(crv, 'list_git_tags', return_value={'v1.19.0rc0'}):
exit_code = self.run_main('--require-untagged')
self.assertEqual(exit_code, 0)

def test_fails_when_version_file_is_missing(self) -> None:
self.version_path.unlink()
with self.assertLogs(crv.logger, level='ERROR') as captured_logs:
exit_code = self.run_main()
self.assertEqual(exit_code, 1)
self.assertIn('Cannot read', captured_logs.output[0])

def test_fails_when_tag_does_not_match(self) -> None:
with self.assertLogs(crv.logger, level='ERROR'):
exit_code = self.run_main('--tag', 'v1.19.0rc2')
self.assertEqual(exit_code, 1)


if __name__ == '__main__':
unittest.main()
Loading
Loading