diff --git a/.changelog/.gitignore b/.changelog/.gitignore new file mode 100644 index 0000000000..f935021a8f --- /dev/null +++ b/.changelog/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 39fc5f8e63..877fa3903a 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -1,6 +1,6 @@ -# This action requires that any PR targeting the main branch should touch at -# least one CHANGELOG file. If a CHANGELOG entry is not required, add the "Skip -# Changelog" label to disable this action. +# This action requires that any PR targeting the main branch should add a +# changelog fragment file in the .changelog/ directory. If a changelog entry +# is not required, add the "Skip Changelog" label to disable this action. name: changelog @@ -22,18 +22,40 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Check for CHANGELOG changes + - name: Fetch base branch + run: git fetch origin ${{ github.base_ref }} --depth=1 + + - name: Ensure no direct changes to CHANGELOG.md run: | - # Only the latest commit of the feature branch is available - # automatically. To diff with the base branch, we need to - # fetch that too (and we only need its latest commit). - git fetch origin ${{ github.base_ref }} --depth=1 - if [[ $(git diff --name-only FETCH_HEAD | grep CHANGELOG) ]] + if [[ $(git diff --name-only FETCH_HEAD -- 'CHANGELOG.md') ]] then - echo "A CHANGELOG was modified. Looks good!" - else - echo "No CHANGELOG was modified." - echo "Please add a CHANGELOG entry, or add the \"Skip Changelog\" label if not required." + echo "CHANGELOG.md should not be directly modified." + echo "Please add a changelog fragment file to the .changelog/ directory instead." + echo "" + echo "Create a fragment with:" + echo " tox -e new-changelog -- ${{ github.event.pull_request.number }} TYPE \"Description\"" + echo "where TYPE is one of: added, changed, deprecated, removed, fixed" + echo "" + echo "Or add the \"Skip Changelog\" label if this job should be skipped." + false + fi + + - name: Install towncrier + run: pip install towncrier + + - name: Check for changelog fragment + run: | + if ! towncrier check --compare-with origin/${{ github.base_ref }}; then + echo "" + echo "No changelog fragment found for this PR." + echo "" + echo "Create a fragment with:" + echo " tox -e new-changelog -- ${{ github.event.pull_request.number }} TYPE \"Description\"" + echo "where TYPE is one of: added, changed, deprecated, removed, fixed" + echo "" + echo "Or add the \"Skip Changelog\" label if this job should be skipped." false fi diff --git a/.github/workflows/misc.yml b/.github/workflows/misc.yml index 7c5d4285f9..6ee2df60e7 100644 --- a/.github/workflows/misc.yml +++ b/.github/workflows/misc.yml @@ -29,6 +29,44 @@ env: jobs: + changelog: + name: changelog + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e changelog + + new-changelog: + name: new-changelog + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e new-changelog + spellcheck: name: spellcheck runs-on: ubuntu-latest diff --git a/.github/workflows/prepare-patch-release.yml b/.github/workflows/prepare-patch-release.yml index 8414d82111..a4856eafdd 100644 --- a/.github/workflows/prepare-patch-release.yml +++ b/.github/workflows/prepare-patch-release.yml @@ -14,8 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install toml - run: pip install toml + - name: Install dependencies + run: pip install toml towncrier - run: | if [[ ! $GITHUB_REF_NAME =~ ^release/v[0-9]+\.[0-9]+\.x-0\.[0-9]+bx$ ]]; then @@ -23,11 +23,6 @@ jobs: exit 1 fi - if ! grep --quiet "^## Unreleased$" CHANGELOG.md; then - echo the change log is missing an \"Unreleased\" section - exit 1 - fi - - name: Set environment variables run: | stable_version=$(./scripts/eachdist.py version --mode stable) @@ -62,10 +57,8 @@ jobs: - name: Update version run: .github/scripts/update-version-patch.sh $STABLE_VERSION $UNSTABLE_VERSION $STABLE_VERSION_PREV $UNSTABLE_VERSION_PREV - - name: Update the change log with the approximate release date - run: | - date=$(date "+%Y-%m-%d") - sed -Ei "s/^## Unreleased$/## Version ${STABLE_VERSION}\/${UNSTABLE_VERSION} ($date)/" CHANGELOG.md + - name: Generate changelog + run: towncrier build --yes --version "$STABLE_VERSION/$UNSTABLE_VERSION" - name: Use CLA approved github bot run: .github/scripts/use-cla-approved-github-bot.sh @@ -99,3 +92,31 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh pr edit ${{ steps.create_pr.outputs.pr_url }} --add-label "prepare-release" + + - uses: actions/checkout@v4 + with: + ref: main + + - name: Use CLA approved github bot + run: .github/scripts/use-cla-approved-github-bot.sh + + - name: Backport patch release changelog to main + env: + GITHUB_TOKEN: ${{ steps.otelbot-token.outputs.token }} + run: | + release_branch="otelbot/prepare-release-${STABLE_VERSION}-${UNSTABLE_VERSION}" + message="Backport ${STABLE_VERSION}/${UNSTABLE_VERSION} changelog" + body="Backport \`${STABLE_VERSION}/${UNSTABLE_VERSION}\` changelog" + branch="otelbot/backport-changelog-from-${STABLE_VERSION}-${UNSTABLE_VERSION}" + + git fetch origin $release_branch + + # Copy the updated CHANGELOG.md from the release branch + git checkout $release_branch -- CHANGELOG.md + git commit -m "$message" + + git push origin HEAD:$branch + gh pr create --title "$message" \ + --body "$body" \ + --head $branch \ + --base main diff --git a/.github/workflows/prepare-release-branch.yml b/.github/workflows/prepare-release-branch.yml index ee8e971caf..47874581a3 100644 --- a/.github/workflows/prepare-release-branch.yml +++ b/.github/workflows/prepare-release-branch.yml @@ -27,11 +27,6 @@ jobs: exit 1 fi - if ! grep --quiet "^## Unreleased$" CHANGELOG.md; then - echo the change log is missing an \"Unreleased\" section - exit 1 - fi - if [[ ! -z $PRERELEASE_VERSION ]]; then stable_version=$(./scripts/eachdist.py version --mode stable) stable_version=${stable_version//.dev/} @@ -50,8 +45,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install toml - run: pip install toml + - name: Install dependencies + run: pip install toml towncrier - name: Create release branch env: @@ -88,10 +83,8 @@ jobs: - name: Update version run: .github/scripts/update-version.sh $STABLE_VERSION $UNSTABLE_VERSION - - name: Update the change log with the approximate release date - run: | - date=$(date "+%Y-%m-%d") - sed -Ei "s/^## Unreleased$/## Version ${STABLE_VERSION}\/${UNSTABLE_VERSION} ($date)/" CHANGELOG.md + - name: Generate changelog + run: towncrier build --yes --version "$STABLE_VERSION/$UNSTABLE_VERSION" - name: Use CLA approved github bot run: .github/scripts/use-cla-approved-github-bot.sh @@ -135,8 +128,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install toml - run: pip install toml + - name: Install dependencies + run: pip install toml towncrier - name: Set environment variables env: @@ -184,11 +177,8 @@ jobs: - name: Update version run: .github/scripts/update-version.sh $STABLE_NEXT_VERSION $UNSTABLE_NEXT_VERSION - - name: Update the change log on main - run: | - # the actual release date on main will be updated at the end of the release workflow - date=$(date "+%Y-%m-%d") - sed -Ei "s/^## Unreleased$/## Unreleased\n\n## Version ${STABLE_VERSION}\/${UNSTABLE_VERSION} ($date)/" CHANGELOG.md + - name: Generate changelog + run: towncrier build --yes --version "$STABLE_VERSION/$UNSTABLE_VERSION" - name: Use CLA approved github bot run: .github/scripts/use-cla-approved-github-bot.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 01486fbda9..f98002134c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,3 +19,11 @@ repos: - id: rstcheck additional_dependencies: ['rstcheck[sphinx]'] args: ["--report-level", "warning"] + - repo: local + hooks: + - id: changelog + name: changelog fragment check + language: system + entry: python scripts/check_changelog_fragment.py + pass_filenames: false + always_run: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 91aaf9a7bc..feb1ed62e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > [!IMPORTANT] > We are working on stabilizing the Log signal that would require making deprecations and breaking changes. We will try to reduce the releases that may require an update to your code, especially for instrumentations or for sdk developers. + + ## Unreleased - Apply fixes for `UP` ruff rule diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 820c0f3e10..84ae63973a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,45 @@ You can run `tox` with the following arguments: - `tox -e tracecontext` to run integration tests for tracecontext. - `tox -e precommit` to run all `pre-commit` actions +### Changelog + +This project uses [towncrier](https://towncrier.readthedocs.io/) to manage the changelog. Instead of editing `CHANGELOG.md` directly, each PR should include a changelog fragment file in the `.changelog/` directory. + +**Creating a changelog fragment:** + +```console +tox -e new-changelog -- PR_NUMBER TYPE "Description of the change" +``` + +Where `TYPE` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`. + +For example: + +```console +tox -e new-changelog -- 1234 added "`opentelemetry-sdk`: add support for new feature" +``` + +This creates a file `.changelog/1234.added` containing the description. You can also create the file manually — it's just a text file with the description on one line. + +**Writing a good changelog entry:** + +- Write in imperative tone, as if completing the phrase "This change will..." +- Keep entries concise — ideally under 80 characters +- Prefix with the affected package name when applicable (e.g. `` `opentelemetry-sdk`: ... ``) +- Don't include the PR number — towncrier adds it automatically + +**Preview the changelog:** + +```console +tox -e changelog +``` + +Running `tox -e precommit` will check that a changelog fragment exists for your branch and remind you to create one if missing. + +The CI will also verify that a changelog fragment exists and that `CHANGELOG.md` is not directly modified. + +If your change does not need a changelog entry, add the "Skip Changelog" label to the PR. + `ruff check` and `ruff format` are executed when `tox -e ruff` is run. We strongly recommend you to configure [pre-commit](https://pre-commit.com/) locally to run `ruff` and `rstcheck` automatically before each commit by installing it as git hooks. You just need to [install pre-commit](https://pre-commit.com/#install) in your environment: ```console diff --git a/pyproject.toml b/pyproject.toml index 83b489aa94..66e4a17920 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -165,6 +165,40 @@ target-python-version = "3.10" custom-template-dir = "opentelemetry-sdk/codegen" additional-imports = "typing.ClassVar,opentelemetry.sdk._configuration._common._additional_properties" +[tool.towncrier] +directory = ".changelog" +filename = "CHANGELOG.md" +start_string = "\n" +template = "scripts/changelog_template.j2" +issue_format = "[#{issue}](https://github.com/open-telemetry/opentelemetry-python/pull/{issue})" +wrap = true # wrap fragments to 79 char line length +issue_pattern = "^(\\d+)" # only PR numbers as fragment prefix (e.g. 1234.fixed) + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true + [dependency-groups] dev = [ "tox", @@ -172,4 +206,5 @@ dev = [ "pre-commit", "datamodel-code-generator[http]", "datamodel-code-generator[ruff]", + "towncrier", ] diff --git a/scripts/changelog_template.j2 b/scripts/changelog_template.j2 new file mode 100644 index 0000000000..55ac56fe53 --- /dev/null +++ b/scripts/changelog_template.j2 @@ -0,0 +1,24 @@ +## Version {{ versiondata.version }} ({{ versiondata.date }}) + +{% for section, _ in sections.items() %} +{%- if section %}{{ section }}{% endif -%} + +{% if sections[section] %} +{% for category, val in definitions.items() if category in sections[section] %} +### {{ definitions[category]['name'] }} + +{% for text, values in sections[section][category].items() %} +{% if "\n - " in text or '\n * ' in text %} +{%- set main_text, sub_items = text.split('\n', 1) %} +- {{ main_text }} ({{ values|join(', ') }}) +{% if sub_items %} + {{- sub_items }} +{% endif %} +{% else %} +- {{ text }} ({{ values|join(', ') }}) +{% endif %} +{% endfor %} + +{% endfor %} +{% endif %} +{% endfor %} diff --git a/scripts/check_changelog_fragment.py b/scripts/check_changelog_fragment.py new file mode 100644 index 0000000000..6a45f30598 --- /dev/null +++ b/scripts/check_changelog_fragment.py @@ -0,0 +1,75 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Check for changelog fragments on feature branches. + +Used as a pre-commit hook via .pre-commit-config.yaml (runs with tox -e precommit). +""" + +import subprocess +import sys + + +def main(): + # Find the merge base with origin/main + try: + merge_base = subprocess.run( + ["git", "merge-base", "origin/main", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except subprocess.CalledProcessError: + # Can't determine merge base (e.g. shallow clone, no remote) + return 0 + + # Check if we're on main itself + try: + current_ref = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except subprocess.CalledProcessError: + return 0 + + if merge_base == current_ref: + # On main or no divergence, skip check + return 0 + + # Check if any changelog fragments have been added + try: + diff_output = subprocess.run( + [ + "git", + "diff", + "--diff-filter=A", + "--name-only", + merge_base, + "--", + ".changelog/", + ], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except subprocess.CalledProcessError: + return 0 + + if diff_output: + # Fragment(s) found + return 0 + + print("⚠ No changelog fragment found on this branch.") + print() + print("Create one with:") + print(' tox -e new-changelog -- PR_NUMBER TYPE "Description"') + print(" where TYPE is one of: added, changed, deprecated, removed, fixed") + print() + print("Or skip this check with: SKIP=changelog tox -e precommit") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tox.ini b/tox.ini index 22bdbbd77d..c79ee2389e 100644 --- a/tox.ini +++ b/tox.ini @@ -103,6 +103,8 @@ envlist = lint-opentelemetry-test-utils lint-license-header-check + changelog + new-changelog spellcheck tracecontext typecheck @@ -355,6 +357,28 @@ commands = commands_post = docker-compose down -v +[testenv:changelog] +description = Preview the changelog that would be generated from current fragments +deps = + towncrier +commands = + towncrier build --draft --version Unreleased {posargs} + +[testenv:new-changelog] +description = Create a new changelog fragment: tox -e new-changelog -- PR_NUMBER TYPE "DESCRIPTION" +deps = +allowlist_externals = + python +commands = + python -c "import sys; \ + args = sys.argv[1:]; \ + len(args) >= 3 or sys.exit('Usage: tox -e new-changelog -- PR_NUMBER TYPE DESCRIPTION\\nTYPE must be one of: added, changed, deprecated, removed, fixed'); \ + pr, typ, desc = args[0], args[1], ' '.join(args[2:]); \ + typ in ('added', 'changed', 'deprecated', 'removed', 'fixed') or sys.exit(f'Invalid type: {{typ}}. Must be one of: added, changed, deprecated, removed, fixed'); \ + open(f'.changelog/{{pr}}.{{typ}}', 'w').write(desc + '\\n'); \ + print(f'Created .changelog/{{pr}}.{{typ}}')" \ + {posargs} + [testenv:lint-license-header-check] commands = python {toxinidir}/scripts/check_license_header.py diff --git a/uv.lock b/uv.lock index e3402bd70b..0a37927e0a 100644 --- a/uv.lock +++ b/uv.lock @@ -1054,6 +1054,7 @@ dependencies = [ dev = [ { name = "datamodel-code-generator", extra = ["http", "ruff"] }, { name = "pre-commit" }, + { name = "towncrier" }, { name = "tox" }, { name = "tox-uv" }, ] @@ -1082,6 +1083,7 @@ dev = [ { name = "datamodel-code-generator", extras = ["http"] }, { name = "datamodel-code-generator", extras = ["ruff"] }, { name = "pre-commit" }, + { name = "towncrier" }, { name = "tox" }, { name = "tox-uv", specifier = ">=1" }, ] @@ -1750,6 +1752,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "towncrier" +version = "25.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, +] + [[package]] name = "tox" version = "4.52.1"