diff --git a/.github/scripts/use-cla-approved-github-bot.sh b/.github/scripts/use-cla-approved-github-bot.sh new file mode 100755 index 00000000..fc47865a --- /dev/null +++ b/.github/scripts/use-cla-approved-github-bot.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e + +git config user.name otelbot +git config user.email 197425009+otelbot@users.noreply.github.com \ No newline at end of file diff --git a/.github/workflows/_prepare-release-package.yml b/.github/workflows/_prepare-release-package.yml new file mode 100644 index 00000000..c218af26 --- /dev/null +++ b/.github/workflows/_prepare-release-package.yml @@ -0,0 +1,85 @@ +name: _Prepare release package +on: + workflow_call: + inputs: + package: + required: true + type: string + pr_title_prefix: + required: false + type: string + default: "" + add_release_label: + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + prepare: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify prerequisites + run: | + if [[ "$GITHUB_REF_NAME" != "main" ]]; then + echo "This workflow should only be run against main" + exit 1 + fi + + version_dev=$(./scripts/eachdist.py version --package ${{ inputs.package }}) + if [[ ! "$version_dev" =~ \.dev$ ]]; then + echo "Expected a .dev version on main, got ${version_dev}" + exit 1 + fi + + echo "VERSION=${version_dev%.dev}" >> $GITHUB_ENV + echo "PACKAGE_NAME=${{ inputs.package }}" >> $GITHUB_ENV + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install tox and towncrier + run: | + pip install tox towncrier==25.8.0 + + - name: Prepare package for release + run: ./scripts/prepare_package_for_release.sh "${{ inputs.package }}" + + - name: Use CLA approved github bot + run: .github/scripts/use-cla-approved-github-bot.sh + + - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + id: otelbot-token + with: + app-id: ${{ vars.OTELBOT_APP_ID }} + private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + + - name: Create pull request against main + env: + GITHUB_TOKEN: ${{ steps.otelbot-token.outputs.token }} + run: | + prefix="${{ inputs.pr_title_prefix }}" + message="${prefix}Prepare release for ${PACKAGE_NAME} v${VERSION}" + body="Prepare \`${PACKAGE_NAME}\` v\`${VERSION}\` for release." + branch="otelbot/prepare-${PACKAGE_NAME}-v${VERSION}" + + git commit -a -m "$message" + git push origin HEAD:"$branch" + pr_url=$(gh pr create --title "$message" \ + --body "$body" \ + --head "$branch" \ + --base main \ + --json url --jq .url) + + if [[ "${{ inputs.add_release_label }}" == "true" ]]; then + gh pr edit "$pr_url" --add-label release + fi diff --git a/.github/workflows/_release-package.yml b/.github/workflows/_release-package.yml new file mode 100644 index 00000000..d370f4a4 --- /dev/null +++ b/.github/workflows/_release-package.yml @@ -0,0 +1,218 @@ +name: _Release package +on: + workflow_call: + inputs: + package: + required: true + type: string + bump_after_release: + required: false + type: boolean + default: false + ref: + required: false + type: string + default: "" + on_main: + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + release: + environment: pypi + permissions: + contents: write + pull-requests: write + id-token: write + runs-on: ubuntu-latest + steps: + - name: Resolve release target + id: target + run: | + if [[ "${{ inputs.on_main }}" == "true" || "$GITHUB_REF_NAME" == "main" ]]; then + echo "on_main=true" >> "$GITHUB_OUTPUT" + elif [[ "$GITHUB_REF_NAME" == package-release/${{ inputs.package }}/v* ]]; then + echo "on_main=false" >> "$GITHUB_OUTPUT" + else + echo "Run on main or package-release/${{ inputs.package }}/v*" + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + - name: Set environment variables + run: | + version=$(./scripts/eachdist.py version --package ${{ inputs.package }}) + if [[ $version =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + if [[ $patch != 0 ]]; then + prior_version_when_patch="${major}.${minor}.$((patch - 1))" + fi + elif [[ $version =~ ^([0-9]+)\.([0-9]+)b([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + if [[ $patch != 0 ]]; then + prior_version_when_patch="${major}.${minor}b$((patch - 1))" + fi + else + echo "unexpected version: $version" + exit 1 + fi + + if [[ "${{ steps.target.outputs.on_main }}" == "true" && "$version" == *.dev ]]; then + echo "version on main still has a .dev suffix; merge the prepare PR first" + exit 1 + fi + + path=$(./scripts/eachdist.py find-package --package ${{ inputs.package }}) + echo "CHANGELOG=./$path/CHANGELOG.md" >> $GITHUB_ENV + echo "PACKAGE_NAME=${{ inputs.package }}" >> $GITHUB_ENV + echo "VERSION=$version" >> $GITHUB_ENV + echo "RELEASE_TAG=${{ inputs.package }}==$version" >> $GITHUB_ENV + echo "PRIOR_VERSION_WHEN_PATCH=$prior_version_when_patch" >> $GITHUB_ENV + if [[ "${{ steps.target.outputs.on_main }}" == "true" ]]; then + echo "RELEASE_TARGET=main" >> $GITHUB_ENV + else + echo "RELEASE_TARGET=$GITHUB_REF_NAME" >> $GITHUB_ENV + fi + + - name: Check that changelog update was merged to main + if: steps.target.outputs.on_main == 'true' + run: | + if [[ -z $PRIOR_VERSION_WHEN_PATCH ]]; then + if ! grep --quiet "^## Version ${VERSION}" ${CHANGELOG}; then + echo "The prepare-release PR needs to be merged first" + exit 1 + fi + fi + + - uses: actions/checkout@v4 + if: steps.target.outputs.on_main != 'true' + with: + ref: main + + - name: Check that changelog update was merged to main (backport branch) + if: steps.target.outputs.on_main != 'true' + run: | + if [[ -z $PRIOR_VERSION_WHEN_PATCH ]]; then + if ! grep --quiet "^## Version ${VERSION}" ${CHANGELOG}; then + echo "The prepare-release PR needs to be merged first" + exit 1 + fi + fi + + - uses: actions/checkout@v4 + if: steps.target.outputs.on_main != 'true' + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Build wheels + run: ./scripts/build_a_package.sh + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + skip-existing: true + + - name: Generate release notes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/generate_release_notes.sh + + - name: Create GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create --target "$RELEASE_TARGET" \ + --title "${PACKAGE_NAME} ${VERSION}" \ + --notes-file /tmp/release-notes.txt \ + "$RELEASE_TAG" + + - uses: actions/checkout@v4 + with: + ref: main + + - name: Copy changelog updates to main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/merge_changelog_to_main.sh + + - name: Use CLA approved github bot + run: .github/scripts/use-cla-approved-github-bot.sh + + - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + id: otelbot-token + with: + app-id: ${{ vars.OTELBOT_APP_ID }} + private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + + - name: Open pull request for changelog updates on main + env: + GITHUB_TOKEN: ${{ steps.otelbot-token.outputs.token }} + run: | + message="Copy changelog updates for ${PACKAGE_NAME} v${VERSION}" + body="Copy changelog updates for \`${PACKAGE_NAME}\` v\`${VERSION}\`." + branch="otelbot/changelog-${PACKAGE_NAME}-${VERSION}" + + changes=0 + if [[ -z $PRIOR_VERSION_WHEN_PATCH ]]; then + if ! git diff --quiet; then + changes=1 + fi + else + changes=1 + fi + + if [[ $changes == 0 ]]; then + echo "No changelog updates needed on main" + exit 0 + fi + + git commit -a -m "$message" + git push origin HEAD:"$branch" + gh pr create --title "$message" \ + --body "$body" \ + --head "$branch" \ + --base main + + - name: Bump package version on main after release + if: inputs.bump_after_release && steps.target.outputs.on_main == 'true' + env: + GITHUB_TOKEN: ${{ steps.otelbot-token.outputs.token }} + run: | + git checkout main + git pull origin main + + ./scripts/bump_package_dev_version.sh "${PACKAGE_NAME}" + + if git diff --quiet; then + echo "No version bump needed" + exit 0 + fi + + message="Bump ${PACKAGE_NAME} to next dev version after v${VERSION}" + branch="otelbot/bump-${PACKAGE_NAME}-after-v${VERSION}" + + git commit -a -m "$message" + git push origin HEAD:"$branch" + gh pr create --title "$message" \ + --body "Bump \`${PACKAGE_NAME}\` to the next \`.dev\` version after releasing v\`${VERSION}\`." \ + --head "$branch" \ + --base main diff --git a/.github/workflows/package-prepare-patch-release.yml b/.github/workflows/package-prepare-patch-release.yml new file mode 100644 index 00000000..9f473291 --- /dev/null +++ b/.github/workflows/package-prepare-patch-release.yml @@ -0,0 +1,93 @@ +name: "[Package] Prepare patch release" +on: + workflow_dispatch: + inputs: + package: + type: choice + options: + - opentelemetry-instrumentation-genai-anthropic + - opentelemetry-instrumentation-google-genai + - opentelemetry-instrumentation-genai-langchain + - opentelemetry-instrumentation-genai-openai-agents + - opentelemetry-instrumentation-genai-openai + - opentelemetry-util-genai + description: 'Package to be released' + required: true +permissions: + contents: read +run-name: "[Package][${{ inputs.package }}] Prepare patch release" + +jobs: + prepare-patch-release: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify prerequisites + id: verify + run: | + on_main=false + on_backport=false + case "$GITHUB_REF_NAME" in + main) + on_main=true + ;; + package-release/${{ inputs.package }}/v*) + on_backport=true + ;; + *) + echo "Run on main (current minor patch) or package-release/${{ inputs.package }}/v* (backport)" + exit 1 + ;; + esac + + echo "on_main=${on_main}" >> "$GITHUB_OUTPUT" + echo "on_backport=${on_backport}" >> "$GITHUB_OUTPUT" + echo "PACKAGE_NAME=${{ inputs.package }}" >> "$GITHUB_ENV" + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install tox and towncrier + run: pip install tox towncrier==25.8.0 + + - name: Prepare patch release on main + if: steps.verify.outputs.on_main == 'true' + run: ./scripts/prepare_package_for_release.sh "${{ inputs.package }}" + + - name: Prepare patch release on backport branch + if: steps.verify.outputs.on_backport == 'true' + run: ./scripts/prepare_package_for_patch_release.sh "${{ inputs.package }}" + + - name: Set pull request title version + run: | + version=$(./scripts/eachdist.py version --package "${{ inputs.package }}") + echo "VERSION=${version}" >> "$GITHUB_ENV" + + - name: Use CLA approved github bot + run: .github/scripts/use-cla-approved-github-bot.sh + + - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + id: otelbot-token + with: + app-id: ${{ vars.OTELBOT_APP_ID }} + private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + + - name: Create pull request + env: + GITHUB_TOKEN: ${{ steps.otelbot-token.outputs.token }} + run: | + message="Prepare patch release for ${PACKAGE_NAME} v${VERSION}" + branch="otelbot/patch-${PACKAGE_NAME}-v${VERSION}" + + git commit -a -m "$message" + git push origin HEAD:"$branch" + gh pr create --title "$message" \ + --body "$message." \ + --head "$branch" \ + --base "$GITHUB_REF_NAME" diff --git a/.github/workflows/package-prepare-release.yml b/.github/workflows/package-prepare-release.yml new file mode 100644 index 00000000..b357f1f7 --- /dev/null +++ b/.github/workflows/package-prepare-release.yml @@ -0,0 +1,27 @@ +name: "[Package] Prepare release" +on: + workflow_dispatch: + inputs: + package: + type: choice + options: + - opentelemetry-instrumentation-genai-anthropic + - opentelemetry-instrumentation-google-genai + - opentelemetry-instrumentation-genai-langchain + - opentelemetry-instrumentation-genai-openai-agents + - opentelemetry-instrumentation-genai-openai + - opentelemetry-util-genai + description: 'Package to be released' + required: true + +permissions: + contents: read + +run-name: "[Package][${{ inputs.package }}] Prepare release" + +jobs: + prepare: + uses: ./.github/workflows/_prepare-release-package.yml + with: + package: ${{ inputs.package }} + secrets: inherit diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml new file mode 100644 index 00000000..374dfe1f --- /dev/null +++ b/.github/workflows/package-release.yml @@ -0,0 +1,28 @@ +name: "[Package] Release" +on: + workflow_dispatch: + inputs: + package: + type: choice + options: + - opentelemetry-instrumentation-genai-anthropic + - opentelemetry-instrumentation-google-genai + - opentelemetry-instrumentation-genai-langchain + - opentelemetry-instrumentation-genai-openai-agents + - opentelemetry-instrumentation-genai-openai + - opentelemetry-util-genai + description: 'Package to be released' + required: true + +permissions: + contents: read + +run-name: "[Package][${{ inputs.package }}] Release" + +jobs: + release: + uses: ./.github/workflows/_release-package.yml + with: + package: ${{ inputs.package }} + bump_after_release: true + secrets: inherit diff --git a/.github/workflows/release-all-prepare.yml b/.github/workflows/release-all-prepare.yml new file mode 100644 index 00000000..4832413c --- /dev/null +++ b/.github/workflows/release-all-prepare.yml @@ -0,0 +1,92 @@ +name: "[All] Prepare release" +on: + workflow_dispatch: + inputs: + bump_type: + type: choice + options: + - minor + - none + description: > + Reserved for future per-package bump granularity. Prepare always strips + .dev and builds changelogs for packages with towncrier fragments. + default: minor + +permissions: + contents: read + +run-name: "[All] Prepare release" + +jobs: + prepare: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify branch + run: | + if [[ "$GITHUB_REF_NAME" != "main" ]]; then + echo "This workflow should only be run against main" + exit 1 + fi + + - name: Find packages with changelog fragments + id: packages + run: | + set -e + mapfile -t packages < <(./scripts/eachdist.py packages-with-changelog-fragments) + if [[ ${#packages[@]} -eq 0 ]]; then + echo "No packages with changelog fragments found" + exit 1 + fi + printf '%s\n' "${packages[@]}" + { + echo "packages<> "$GITHUB_OUTPUT" + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install tox and towncrier + run: pip install tox towncrier==25.8.0 + + - name: Prepare packages for release + run: | + while IFS= read -r package; do + [[ -z "$package" ]] && continue + echo "Preparing ${package}" + ./scripts/prepare_package_for_release.sh "$package" + done <<< "${{ steps.packages.outputs.packages }}" + + - name: Use CLA approved github bot + run: .github/scripts/use-cla-approved-github-bot.sh + + - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + id: otelbot-token + with: + app-id: ${{ vars.OTELBOT_APP_ID }} + private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + + - name: Create combined prepare pull request + env: + GITHUB_TOKEN: ${{ steps.otelbot-token.outputs.token }} + run: | + message="Prepare release for all packages with changelog fragments" + body="Prepare all packages that have towncrier changelog fragments for release." + branch="otelbot/prepare-release-all-$(date +%Y%m%d)" + + git commit -a -m "$message" + git push origin HEAD:"$branch" + pr_url=$(gh pr create --title "$message" \ + --body "$body" \ + --head "$branch" \ + --base main \ + --json url --jq .url) + gh pr edit "$pr_url" --add-label release diff --git a/.github/workflows/release-all.yml b/.github/workflows/release-all.yml new file mode 100644 index 00000000..b4acb7da --- /dev/null +++ b/.github/workflows/release-all.yml @@ -0,0 +1,142 @@ +name: "[All] Release" +on: + workflow_dispatch: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: read + +run-name: "[All] Release" + +jobs: + enumerate: + if: | + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release')) + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.enumerate.outputs.packages }} + ref: ${{ steps.release_ref.outputs.ref }} + steps: + - name: Set release ref + id: release_ref + run: echo "ref=${{ github.event.pull_request.merge_commit_sha || github.sha }}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.release_ref.outputs.ref }} + + - name: Verify manual dispatch runs on main + if: github.event_name == 'workflow_dispatch' + run: | + if [[ "$GITHUB_REF_NAME" != "main" ]]; then + echo "This workflow should only be run against main" + exit 1 + fi + + - name: Enumerate packages ready for release + id: enumerate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + packages=() + while IFS= read -r package; do + [[ -z "$package" ]] && continue + version=$(./scripts/eachdist.py version --package "$package") + if [[ "$version" == *.dev ]]; then + echo "Skipping ${package}: version still has .dev suffix (${version})" + continue + fi + tag="${package}==${version}" + if gh release view "$tag" >/dev/null 2>&1; then + echo "Skipping ${package}: tag ${tag} already exists" + continue + fi + echo "Ready: ${package} v${version}" + packages+=("$package") + done < <(./scripts/eachdist.py list-release-packages) + + if [[ ${#packages[@]} -eq 0 ]]; then + echo "No packages ready for release" + exit 1 + fi + + json=$(printf '%s\n' "${packages[@]}" | jq -R . | jq -s -c .) + echo "packages=${json}" >> "$GITHUB_OUTPUT" + + publish: + needs: enumerate + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.enumerate.outputs.packages) }} + uses: ./.github/workflows/_release-package.yml + with: + package: ${{ matrix.package }} + bump_after_release: false + ref: ${{ needs.enumerate.outputs.ref }} + on_main: true + secrets: inherit + + bump-main: + needs: [enumerate, publish] + if: always() && needs.enumerate.result == 'success' + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - name: Use CLA approved github bot + run: .github/scripts/use-cla-approved-github-bot.sh + + - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + id: otelbot-token + with: + app-id: ${{ vars.OTELBOT_APP_ID }} + private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + + - name: Bump released packages to next dev versions + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.otelbot-token.outputs.token }} + PACKAGES_JSON: ${{ needs.enumerate.outputs.packages }} + run: | + bumped=() + while IFS= read -r package; do + [[ -z "$package" ]] && continue + version=$(./scripts/eachdist.py version --package "$package") + tag="${package}==${version}" + if ! gh release view "$tag" >/dev/null 2>&1; then + echo "Skipping ${package}: release tag ${tag} not found" + continue + fi + if [[ "$version" == *.dev ]]; then + echo "Skipping ${package}: already bumped to ${version}" + continue + fi + ./scripts/bump_package_dev_version.sh "$package" + bumped+=("$package") + done < <(echo "$PACKAGES_JSON" | jq -r '.[]') + + if [[ ${#bumped[@]} -eq 0 ]]; then + echo "No packages to bump" + exit 0 + fi + + message="Bump packages to next dev versions after release" + body="Bump packages to the next \`.dev\` version after release:\n\n$(printf '%s\n' "${bumped[@]}")" + branch="otelbot/bump-after-release-all-$(date +%Y%m%d)" + + git commit -a -m "$message" + git push origin HEAD:"$branch" + gh pr create --title "$message" \ + --body "$body" \ + --head "$branch" \ + --base main diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..f9c3f862 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,164 @@ +# Release process + +Every package releases independently. The default path is a coordinated +**release-all** workflow; per-package workflows are for urgent or partial +releases. + +Releases are **tag-from-`main`**: each publish creates a tag +(`==`) pointing at the release commit on `main`. Backport +branches (`package-release//v*`) are created lazily from an old tag only +when patching an older minor line — not for every release. + +Releases are driven by GitHub Actions workflows. They handle version bumps, +changelog generation (via [towncrier](https://towncrier.readthedocs.io/)), +tagging, PyPI publishing, GitHub releases, and changelog updates on `main`. + +## Release model + +Unlike `opentelemetry-python-contrib`, we do not maintain a long-lived release +branch for every minor. Normal releases tag `main` directly; backport branches +are created on demand from an existing tag when patching an older minor. + +| | opentelemetry-python-contrib | This repo | +|---|---|---| +| Normal release | Long-lived `package-release//v*` branch | Tag on `main` | +| Tag target | Commit on the release branch | Commit on `main` | +| Patch in current line | Commits + tags on the release branch | Tags on `main` | +| Backport to older minor | Same branch (already exists) | Branch from old tag (lazy) | +| Branch sprawl | One branch per package per minor | Branches only for backports | + +## Bulk release (default) + +For releasing every package that has towncrier changelog fragments: + +1. Run the + [`[All] Prepare release`](./.github/workflows/release-all-prepare.yml) + workflow against `main`. + - Finds packages with fragments under `.changelog/`. + - Opens one combined PR on `main` that drops `.dev` suffixes and runs + `towncrier build` for each eligible package. + - Labels the PR `release`. +2. Review and merge the prepare PR. +3. The + [`[All] Release`](./.github/workflows/release-all.yml) + workflow runs automatically when a labelled prepare PR merges (or trigger it + manually against `main`). + - Publishes each ready package to PyPI. + - Creates a GitHub release tag (`==`) on `main` for each. + - Opens a PR bumping released packages back to the next `.dev` version. + +Packages without changelog fragments are skipped during prepare and logged in +the workflow output. + +## Per-package release + +Use when only one package needs to ship, or the rest of the workspace is not +ready for a bulk release. + +1. Run + [`[Package] Prepare release`](./.github/workflows/package-prepare-release.yml) + against `main`. Select the package from the dropdown. + - Opens a PR on `main` that drops the `.dev` suffix and runs + `towncrier build`. +2. Review and merge the prepare PR. +3. Run + [`[Package] Release`](./.github/workflows/package-release.yml) + against `main`. + - Builds the wheel, publishes to PyPI, creates the GitHub release tag, and + opens PRs for any changelog date updates and the next `.dev` bump. + +## Patch release (current minor line) + +1. Land the fix on `main` as a normal PR (with a towncrier fragment). +2. Run + [`[Package] Prepare patch release`](./.github/workflows/package-prepare-patch-release.yml) + against `main`. + - Same mechanics as prepare release: drops `.dev`, runs `towncrier build`. +3. Review and merge the prepare PR. +4. Run + [`[Package] Release`](./.github/workflows/package-release.yml) + against `main`. + +## Backport patch (older minor line) + +1. Create `package-release//v.bx` from the `==.b` + tag if it does not exist yet. +2. Cherry-pick or develop the fix on the branch. +3. Run + [`[Package] Prepare patch release`](./.github/workflows/package-prepare-patch-release.yml) + against the backport branch. + - Bumps the patch version and runs `towncrier build`. +4. Review and merge the prepare PR into the backport branch. +5. Run + [`[Package] Release`](./.github/workflows/package-release.yml) + against the backport branch. + - Tags the backport branch and opens a PR copying changelog updates to + `main`. + +## Pre-existing static `## Unreleased` entries + +Several packages carry CHANGELOG entries that pre-date towncrier (added +before the towncrier marker was inserted). `towncrier build` does **not** +fold them into the generated release section. Before the first towncrier +release of a given package, fold those entries by hand into the new +release section produced by `towncrier build` (or convert them into +fragments first). The do-not-edit comment in each `CHANGELOG.md` flags +this. + +## Adding a new publishable package + +When a new package is ready to ship: + +1. Add its name to the `packages=` list under `[release_packages]` in + `eachdist.ini`. Packages not listed here are skipped by the release + workflows. +2. Add the package to the dropdown options in the per-package workflow files + (`package-prepare-release.yml`, `package-release.yml`, + `package-prepare-patch-release.yml`). +3. Create the PyPI project and register a trusted publisher (*Manage* → + *Publishing* → *Add a new pending publisher*): + +| Field | Value | +|-------|-------| +| PyPI project name | e.g. `opentelemetry-util-genai` | +| Owner | `open-telemetry` | +| Repository name | `opentelemetry-python-genai` | +| Workflow name | `_release-package.yml` | +| Environment name | `pypi` | + +4. Optionally upload the current `.dev` version manually once to prevent + name-squatting, shortly after the introductory PR lands on `main`. + +All packages share the same workflow and environment. The first upload from CI +activates the publisher. + +## Troubleshooting + +### No packages found during `[All] Prepare release` + +At least one publishable package needs a towncrier fragment under +`.changelog/` (any file other than `.gitkeep` / `.gitignore`). + +### PyPI publish failed mid-workflow + +Re-run the release workflow (`[Package] Release` or `[All] Release`). Trusted +Publishing only works from GitHub Actions — there is no repo-stored PyPI token +for manual `twine upload`. + +If the wheel was built but upload failed, fix the underlying issue (PyPI +project missing, trusted publisher misconfigured, environment approval pending) +and re-run. The workflow uses `skip-existing`, so a partial upload is safe to +retry. + +After a successful PyPI upload, re-running picks up remaining steps (GitHub +release tag + follow-up PRs) if those failed. + +### Version still has a `.dev` suffix at release time + +Merge the prepare PR first. Release workflows require a non-`.dev` version in +`version.py`. + +## Out of scope + +- A `backport` workflow (create backport branches manually from release tags + when needed). diff --git a/eachdist.ini b/eachdist.ini index 7bdb949b..65f83f55 100644 --- a/eachdist.ini +++ b/eachdist.ini @@ -13,15 +13,14 @@ packages= all opentelemetry-util-genai -[exclude_release] +[release_packages] +# Packages the release workflows may publish (eachdist.py list-release-packages). packages= opentelemetry-instrumentation-genai-anthropic - opentelemetry-instrumentation-genai-claude-agent-sdk opentelemetry-instrumentation-google-genai opentelemetry-instrumentation-genai-langchain opentelemetry-instrumentation-genai-openai opentelemetry-instrumentation-genai-openai-agents - opentelemetry-instrumentation-genai-weaviate-client opentelemetry-util-genai [lintroots] diff --git a/scripts/build_a_package.sh b/scripts/build_a_package.sh index 2375018b..2fa883e0 100755 --- a/scripts/build_a_package.sh +++ b/scripts/build_a_package.sh @@ -15,8 +15,8 @@ # limitations under the License. # This script builds wheels for a single package when triggered from per-package release -# GitHub workflow (see .github/package-release.yml). -# The wheel is then published to PyPI by the workflow. +# GitHub workflow (see .github/workflows/_release-package.yml). +# The wheel is then published to PyPI via Trusted Publishing in that workflow. set -ev diff --git a/scripts/bump_package_dev_version.sh b/scripts/bump_package_dev_version.sh new file mode 100755 index 00000000..48d22d14 --- /dev/null +++ b/scripts/bump_package_dev_version.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Bump a package from a released version (no .dev suffix) to the next .dev version. + +set -euo pipefail + +package="${1:?usage: bump_package_dev_version.sh PACKAGE}" + +path="./$(./scripts/eachdist.py find-package --package "$package")" +version="$(./scripts/eachdist.py version --package "$package")" +version_file="$(find "$path" -type f -path "**/version.py")" + +if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + if [[ "$patch" != 0 ]]; then + next_version="${major}.${minor}.$((patch + 1)).dev" + else + next_version="${major}.$((minor + 1)).0.dev" + fi +elif [[ "$version" =~ ^([0-9]+)\.([0-9]+)b([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + if [[ "$patch" != 0 ]]; then + next_version="${major}.${minor}b$((patch + 1)).dev" + else + next_version="${major}.$((minor + 1))b0.dev" + fi +else + echo "unexpected version: ${version}" + exit 1 +fi + +sed -i -E "s/__version__\\s*=\\s*\"${version}\"/__version__ = \"${next_version}\"/g" "$version_file" +echo "Bumped ${package} from ${version} to ${next_version}" diff --git a/scripts/eachdist.py b/scripts/eachdist.py index 760befae..d594fc56 100755 --- a/scripts/eachdist.py +++ b/scripts/eachdist.py @@ -292,6 +292,19 @@ def setup_instparser(instparser): required=True, help="Name of the package to find", ) + + listreleaseparser = subparsers.add_parser( + "list-release-packages", + help="List publishable package names from eachdist.ini.", + ) + listreleaseparser.set_defaults(func=list_release_packages_args) + + fragmentsparser = subparsers.add_parser( + "packages-with-changelog-fragments", + help="List publishable packages with towncrier changelog fragments.", + ) + fragmentsparser.set_defaults(func=packages_with_changelog_fragments_args) + return parser.parse_args(args) @@ -720,10 +733,11 @@ def release_args(args): versions = args.versions updated_versions = [] - # remove excluded packages - excluded = cfg["exclude_release"]["packages"].split() + release_packages = list_release_package_names() targets = [ - target for target in targets if basename(target) not in excluded + target + for target in targets + if basename(target) not in release_packages ] for group in versions.split(","): @@ -733,7 +747,9 @@ def release_args(args): packages = None if "packages" in mcfg: packages = [ - pkg for pkg in mcfg["packages"].split() if pkg not in excluded + pkg + for pkg in mcfg["packages"].split() + if pkg not in release_packages ] print(f"update {group} packages to {version}") update_dependencies(targets, version, packages) @@ -750,10 +766,11 @@ def patch_release_args(args): cfg = ConfigParser() cfg.read(str(find_projectroot() / "eachdist.ini")) - # remove excluded packages - excluded = cfg["exclude_release"]["packages"].split() + release_packages = list_release_package_names() targets = [ - target for target in targets if basename(target) not in excluded + target + for target in targets + if basename(target) not in release_packages ] # stable @@ -846,6 +863,42 @@ def find_package_args(args): sys.exit(1) +def list_release_package_names() -> list[str]: + cfg = ConfigParser() + cfg.read(str(find_projectroot() / "eachdist.ini")) + return cfg["release_packages"]["packages"].split() + + +def list_release_packages_args(args): + for package in list_release_package_names(): + print(package) + + +def packages_with_changelog_fragments_args(args): + root = find_projectroot() + packages: list[str] = [] + for package_name in list_release_package_names(): + for package in find_targets_unordered(root): + if package_name != package.name: + continue + changelog_dir = package / ".changelog" + if not changelog_dir.is_dir(): + continue + fragments = [ + fragment + for fragment in changelog_dir.iterdir() + if fragment.is_file() + and fragment.name not in {".gitignore", ".gitkeep"} + ] + if fragments: + packages.append(package_name) + break + if not packages: + sys.exit(1) + for package in packages: + print(package) + + def main(): args = parse_args() args.func(args) diff --git a/scripts/generate_release_notes.sh b/scripts/generate_release_notes.sh new file mode 100755 index 00000000..193e6241 --- /dev/null +++ b/scripts/generate_release_notes.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# This script copies release notes for the current version from CHANGELOG.md file +# and stores them in /tmp/release-notes.txt. +# It also stores them in a /tmp/CHANGELOG_SECTION.md which is used later to copy them over to the +# CHANGELOG in main branch which is done by merge_changelog_to_main script. + +# This script is called from the release workflows (package-release.yml and release.yml). + +set -ev + +# conditional block not indented because of the heredoc +if [[ ! -z $PRIOR_VERSION_WHEN_PATCH ]]; then +cat > /tmp/release-notes.txt << EOF +This is a patch release on the previous $PRIOR_VERSION_WHEN_PATCH release, fixing the issue(s) below. + +EOF +fi + +# CHANGELOG_SECTION.md is also used at the end of the release workflow +# for copying the change log updates to main +sed -n "0,/^## Version ${VERSION}/d;/^## Version /q;p" $CHANGELOG > /tmp/CHANGELOG_SECTION.md + +# the complex perl regex is needed because markdown docs render newlines as soft wraps +# while release notes render them as line breaks +perl -0pe 's/(?> /tmp/release-notes.txt diff --git a/scripts/merge_changelog_to_main.sh b/scripts/merge_changelog_to_main.sh new file mode 100755 index 00000000..ee7d027c --- /dev/null +++ b/scripts/merge_changelog_to_main.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# This script copies release notes for the current version from CHANGELOG.md file +# and stores them in a temporary file for later use in the release workflow + +# This script is called from the release workflows (package-release.yml and release.yml). + +set -ev + +if [[ -z $PRIOR_VERSION_WHEN_PATCH ]]; then + # this was not a patch release, so the version exists already in the CHANGELOG.md + + # update the release date + date=$(gh release view $RELEASE_TAG --json publishedAt --jq .publishedAt | sed 's/T.*//') + sed -Ei "s/## Version ${VERSION}.*/## Version ${VERSION} ($date)/" ${CHANGELOG} + + # the entries are copied over from the release branch to support workflows + # where change log entries may be updated after preparing the release branch + + # copy the portion above the release, up to and including the heading + sed -n "0,/^## Version ${VERSION} ($date)/p" ${CHANGELOG} > /tmp/CHANGELOG.md + + # copy the release notes + cat /tmp/CHANGELOG_SECTION.md >> /tmp/CHANGELOG.md + + # copy the portion below the release + sed -n "0,/^## Version ${VERSION} /d;0,/^## Version /{/^## Version/!d};p" ${CHANGELOG} \ + >> /tmp/CHANGELOG.md + + # update the real CHANGELOG.md + cp /tmp/CHANGELOG.md ${CHANGELOG} +else + # this was a patch release, so the version does not exist already in the CHANGELOG.md + + # copy the portion above the top-most release, not including the heading + sed -n "0,/^## Version /{ /^## Version /!p }" ${CHANGELOG} > /tmp/CHANGELOG.md + + # add the heading + date=$(gh release view $RELEASE_TAG --json publishedAt --jq .publishedAt | sed 's/T.*//') + echo "## Version ${VERSION} ($date)" >> /tmp/CHANGELOG.md + + # copy the release notes + cat /tmp/CHANGELOG_SECTION.md >> /tmp/CHANGELOG.md + + # copy the portion starting from the top-most release + sed -n "/^## Version /,\$p" ${CHANGELOG} >> /tmp/CHANGELOG.md + + # update the real CHANGELOG.md + cp /tmp/CHANGELOG.md ${CHANGELOG} +fi \ No newline at end of file diff --git a/scripts/prepare_package_for_patch_release.sh b/scripts/prepare_package_for_patch_release.sh new file mode 100755 index 00000000..2557075a --- /dev/null +++ b/scripts/prepare_package_for_patch_release.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Prepare a patch release for a package on a backport branch: bump the patch +# version and run towncrier. Expects the current version without a .dev suffix. + +set -euo pipefail + +package="${1:?usage: prepare_package_for_patch_release.sh PACKAGE}" + +path="./$(./scripts/eachdist.py find-package --package "$package")" +changelog="${path}/CHANGELOG.md" + +if [[ ! -f "$changelog" ]]; then + echo "missing ${changelog}" + exit 1 +fi + +version="$(./scripts/eachdist.py version --package "$package")" + +version_file="$(find "$path" -type f -path "**/version.py")" +file_count="$(echo "$version_file" | wc -l | tr -d ' ')" + +if [[ "$file_count" -ne 1 ]]; then + echo "Error: expected one version file, found ${file_count}" + echo "$version_file" + exit 1 +fi + +if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + next_version="${major}.${minor}.$((patch + 1))" +elif [[ "$version" =~ ^([0-9]+)\.([0-9]+)b([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + next_version="${major}.${minor}b$((patch + 1))" +else + echo "unexpected version: '${version}'" + exit 1 +fi + +sed -i -E "s/__version__\\s*=\\s*\"${version}\"/__version__ = \"${next_version}\"/g" "$version_file" + +tox -e generate +towncrier build --yes --version "$next_version" --dir "$(dirname "$changelog")" + +echo "Prepared ${package} for patch release v${next_version}" diff --git a/scripts/prepare_package_for_release.sh b/scripts/prepare_package_for_release.sh new file mode 100755 index 00000000..e64252e4 --- /dev/null +++ b/scripts/prepare_package_for_release.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Prepare a single package for release on the current branch: drop the .dev suffix +# from version.py and run towncrier. Called from release prepare workflows. + +set -euo pipefail + +package="${1:?usage: prepare_package_for_release.sh PACKAGE}" + +path="./$(./scripts/eachdist.py find-package --package "$package")" +changelog="${path}/CHANGELOG.md" + +if [[ ! -f "$changelog" ]]; then + echo "missing ${changelog}" + exit 1 +fi + +version_dev="$(./scripts/eachdist.py version --package "$package")" + +if [[ ! "$version_dev" =~ ^([0-9]+)\.([0-9]+)[\.|b]{1}([0-9]+).*\.dev$ ]]; then + echo "unexpected version: ${version_dev}" + exit 1 +fi + +version="${version_dev%.dev}" + +version_file="$(find "$path" -type f -path "**/version.py")" +file_count="$(echo "$version_file" | wc -l | tr -d ' ')" + +if [[ "$file_count" -ne 1 ]]; then + echo "Error: expected one version file, found ${file_count}" + echo "$version_file" + exit 1 +fi + +sed -i -E "s/__version__\\s*=\\s*\"${version}\\.dev\"/__version__ = \"${version}\"/g" "$version_file" + +tox -e generate +towncrier build --yes --version "$version" --dir "$(dirname "$changelog")" + +echo "Prepared ${package} for release v${version}"