diff --git a/.github/modules.json b/.github/modules.json new file mode 100644 index 0000000..ba1a184 --- /dev/null +++ b/.github/modules.json @@ -0,0 +1,18 @@ +{ + "analytics": { + "tag_prefix": "v", + "pyproject_toml": "pyproject.toml", + "version_files": ["mixpanel/__init__.py"], + "changelog": "CHANGELOG.md", + "readme": "README.md", + "package_name": "mixpanel" + }, + "openfeature": { + "tag_prefix": "openfeature/v", + "pyproject_toml": "openfeature-provider/pyproject.toml", + "version_files": [], + "changelog": "openfeature-provider/CHANGELOG.md", + "readme": "openfeature-provider/README.md", + "package_name": "mixpanel-openfeature" + } +} diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh new file mode 100755 index 0000000..8f289c7 --- /dev/null +++ b/.github/scripts/generate-changelog.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -euo pipefail + +MODULE="$1" +VERSION_LABEL="$2" +REPO_URL="$3" +END_REF="${4:-HEAD}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MODULES_JSON="$SCRIPT_DIR/../modules.json" + +TAG_PREFIX=$(jq -e -r --arg m "$MODULE" '.[$m].tag_prefix' "$MODULES_JSON") || { + echo "Unknown module: $MODULE. Valid modules: $(jq -r 'keys | join(", ")' "$MODULES_JSON")" >&2 + exit 1 +} +TAG_GLOB="${TAG_PREFIX}*" + +PREVIOUS_TAG=$(git tag --sort=-creatordate --list "$TAG_GLOB" | head -1 || true) + +if [ -z "$PREVIOUS_TAG" ]; then + RANGE="$END_REF" +else + RANGE="${PREVIOUS_TAG}..${END_REF}" +fi + +DATE=$(date +%Y-%m-%d) +SAFE_URL=$(printf '%s' "$REPO_URL" | sed 's|[&/\]|\\&|g') + +declare -a FEATURES=() +declare -a FIXES=() +declare -a CHORES=() + +while IFS= read -r line; do + [ -z "$line" ] && continue + MSG=$(echo "$line" | cut -d' ' -f2-) + + if [[ "$MSG" =~ ^(feat|fix|chore)\((${MODULE}|all)\):\ (.+) ]]; then + TYPE="${BASH_REMATCH[1]}" + DESC="${BASH_REMATCH[3]}" + + DESC=$(echo "$DESC" | sed -E "s|\(#([0-9]+)\)|([#\1](${SAFE_URL}/pull/\1))|g") + + case "$TYPE" in + feat) FEATURES+=("$DESC") ;; + fix) FIXES+=("$DESC") ;; + chore) CHORES+=("$DESC") ;; + esac + fi +done < <(git log --oneline "$RANGE") + +echo "## [${VERSION_LABEL}](${REPO_URL}/tree/${VERSION_LABEL}) (${DATE})" +echo "" + +if [ ${#FEATURES[@]} -gt 0 ]; then + echo "### Features" + for entry in "${FEATURES[@]}"; do + echo "- ${entry}" + done + echo "" +fi + +if [ ${#FIXES[@]} -gt 0 ]; then + echo "### Fixes" + for entry in "${FIXES[@]}"; do + echo "- ${entry}" + done + echo "" +fi + +if [ ${#CHORES[@]} -gt 0 ]; then + echo "### Chores" + for entry in "${CHORES[@]}"; do + echo "- ${entry}" + done + echo "" +fi + +if [ -n "$PREVIOUS_TAG" ]; then + echo "[Full Changelog](${REPO_URL}/compare/${PREVIOUS_TAG}...${VERSION_LABEL})" +fi diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml new file mode 100644 index 0000000..59867ed --- /dev/null +++ b/.github/workflows/pr-title-check.yml @@ -0,0 +1,51 @@ +name: PR Title Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + check-title: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: .github/modules.json + sparse-checkout-cone-mode: false + + - name: Check PR title format + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + MODULE_LIST=$(jq -r 'keys | join("|")' .github/modules.json) + # Scope is optional. Bare, scoped to a known module, or scoped to + # `all` (cross-cutting changes that appear in every module's + # changelog) all pass. + MAIN_PATTERN="^(feat|fix|chore)(\((${MODULE_LIST}|all)\))?: .+" + RELEASE_PATTERN="^release: .+" + + if [[ "$PR_TITLE" =~ $MAIN_PATTERN ]] || [[ "$PR_TITLE" =~ $RELEASE_PATTERN ]]; then + echo "PR title is valid: $PR_TITLE" + exit 0 + fi + + echo "PR title does not match the required format." + echo "" + echo " Got: $PR_TITLE" + echo "" + echo "Expected one of:" + echo " feat: description" + echo " fix: description" + echo " chore: description" + echo " feat(|all): description" + echo " fix(|all): description" + echo " chore(|all): description" + echo " release: description" + echo "" + echo "Valid scopes: ${MODULE_LIST//|/, }, all" + exit 1 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..e178045 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,238 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + module: + description: 'Module to release (must match a key in .github/modules.json)' + required: true + type: string + version: + description: 'Release version (e.g., 1.3.0 or 1.3.0-beta.1)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: prepare-release-${{ inputs.module }} + cancel-in-progress: false + +jobs: + prepare: + name: "Prepare ${{ inputs.module }} ${{ inputs.version }}" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate inputs + env: + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "::error::Invalid version format: $VERSION" + exit 1 + fi + jq -e --arg m "$MODULE" '.[$m]' .github/modules.json > /dev/null || { + echo "::error::Unknown module '$MODULE'. Valid modules: $(jq -r 'keys | join(", ")' .github/modules.json)" + exit 1 + } + + - name: Resolve module config + id: config + env: + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + run: | + MODULE_CONFIG=$(jq -e --arg m "$MODULE" '.[$m]' .github/modules.json) + + TAG_PREFIX=$(echo "$MODULE_CONFIG" | jq -r '.tag_prefix') + { + echo "tag=${TAG_PREFIX}${VERSION}" + echo "tag_prefix=${TAG_PREFIX}" + echo "pyproject_toml=$(echo "$MODULE_CONFIG" | jq -r '.pyproject_toml')" + # Newline-separated list of additional Python source files holding the version literal. + echo "version_files<> "$GITHUB_OUTPUT" + + - name: Validate version not already released + env: + TAG: ${{ steps.config.outputs.tag }} + run: | + if git tag -l "$TAG" | grep -q .; then + echo "::error::Tag $TAG already exists" + exit 1 + fi + + - name: Clean up existing release branch and PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ steps.config.outputs.branch }} + run: | + EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true) + if [[ -n "$EXISTING_PR" ]]; then + echo "Closing existing PR #$EXISTING_PR and deleting branch" + gh pr close "$EXISTING_PR" --delete-branch + elif git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "Deleting orphaned branch $BRANCH" + git push origin --delete "$BRANCH" + fi + + - name: Create release branch + env: + BRANCH: ${{ steps.config.outputs.branch }} + run: git checkout -b "$BRANCH" + + - name: Bump version + env: + VERSION: ${{ inputs.version }} + PYPROJECT_TOML: ${{ steps.config.outputs.pyproject_toml }} + VERSION_FILES: ${{ steps.config.outputs.version_files }} + run: | + set -euo pipefail + + # If pyproject.toml uses dynamic version, the canonical source lives + # in the listed Python file(s) (e.g. `__version__ = "X.Y.Z"`). Otherwise + # the literal `version = "X.Y.Z"` line in pyproject.toml is the source. + IS_DYNAMIC=$(python3 - </dev/null; then + echo "::error::No __version__ literal found in $vf" + exit 1 + fi + OLD=$(grep -E "^__version__\s*=\s*['\"][^'\"]+['\"]" "$vf" | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/") + echo "Bumping ${vf}: ${OLD} -> ${VERSION}" + sed -i.bak -E "s|^__version__[[:space:]]*=[[:space:]]*['\"][^'\"]+['\"]|__version__ = \"${VERSION}\"|" "$vf" + rm -f "${vf}.bak" + done <<< "$VERSION_FILES" + else + if ! grep -E "^version[[:space:]]*=[[:space:]]*\"[^\"]+\"" "$PYPROJECT_TOML" >/dev/null; then + echo "::error::No literal version line found in $PYPROJECT_TOML" + exit 1 + fi + OLD=$(grep -E "^version[[:space:]]*=[[:space:]]*\"[^\"]+\"" "$PYPROJECT_TOML" | head -1 | sed -E "s/.*\"([^\"]+)\".*/\1/") + echo "Bumping ${PYPROJECT_TOML}: ${OLD} -> ${VERSION}" + sed -i.bak -E "s|^version[[:space:]]*=[[:space:]]*\"[^\"]+\"|version = \"${VERSION}\"|" "$PYPROJECT_TOML" + rm -f "${PYPROJECT_TOML}.bak" + fi + + - name: Update README version header + env: + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + TAG: ${{ steps.config.outputs.tag }} + README: ${{ steps.config.outputs.readme }} + run: | + DATE=$(date +"%B %d, %Y") + # The `1,/pat/` address range bounds substitution to the first match + # so older changelog entries inside the README aren't trampled. + sed -i -E \ + "1,/^##### _.*_ - \[.*\]\(.*\)\$/ s|^##### _.*_ - \[.*\]\(.*\)\$|##### _${DATE}_ - [${TAG}](${REPO_URL}/releases/tag/${TAG})|" \ + "$README" + + - name: Generate changelog + env: + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + MODULE: ${{ inputs.module }} + TAG: ${{ steps.config.outputs.tag }} + CHANGELOG_FILE: ${{ steps.config.outputs.changelog }} + run: | + CHANGELOG=$(.github/scripts/generate-changelog.sh \ + "$MODULE" "$TAG" "$REPO_URL" HEAD) + + if [ -f "$CHANGELOG_FILE" ]; then + { + printf '# Changelog\n\n%s\n' "$CHANGELOG" + sed '1{/^# Changelog$/d;}' "$CHANGELOG_FILE" + } > CHANGELOG.new.md + mv CHANGELOG.new.md "$CHANGELOG_FILE" + else + mkdir -p "$(dirname "$CHANGELOG_FILE")" + printf '# Changelog\n\n%s\n' "$CHANGELOG" > "$CHANGELOG_FILE" + fi + + - name: Commit and push + env: + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + BRANCH: ${{ steps.config.outputs.branch }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "release: prepare ${MODULE} ${VERSION}" + git push origin "$BRANCH" + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + TAG: ${{ steps.config.outputs.tag }} + TAG_PREFIX: ${{ steps.config.outputs.tag_prefix }} + PYPROJECT_TOML: ${{ steps.config.outputs.pyproject_toml }} + CHANGELOG_FILE: ${{ steps.config.outputs.changelog }} + README: ${{ steps.config.outputs.readme }} + BRANCH: ${{ steps.config.outputs.branch }} + run: | + gh pr create \ + --title "release: prepare ${MODULE} ${VERSION}" \ + --body "$(cat <> "$GITHUB_OUTPUT" + + echo "Resolved tag '$TAG' -> module '$MODULE', version '$VERSION', project dir '$PROJECT_DIR'" + + - name: Verify tag commit is on master + env: + TAG: ${{ github.ref_name }} + run: | + git fetch origin master + TAG_SHA=$(git rev-parse HEAD) + if ! git merge-base --is-ancestor "$TAG_SHA" origin/master; then + echo "::error::Tag '$TAG' ($TAG_SHA) is not an ancestor of origin/master." + echo "Tags must be pushed from a commit on the master branch." + exit 1 + fi + + - name: Validate package version matches tag + env: + VERSION: ${{ steps.module.outputs.version }} + PYPROJECT_TOML: ${{ steps.module.outputs.pyproject_toml }} + VERSION_FILES: ${{ steps.module.outputs.version_files }} + run: | + set -euo pipefail + PKG_VERSION=$(python3 - </dev/null > release_notes.md || true + if [ ! -s release_notes.md ]; then + echo "Release $TAG" > release_notes.md + fi + echo "--- release_notes.md ---" + cat release_notes.md + + - name: Create draft GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + # Idempotent: if a draft release for this tag already exists, leave it alone. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release for $TAG already exists; skipping creation" + else + gh release create "$TAG" \ + --draft \ + --title "$TAG" \ + --notes-file release_notes.md + fi + + # Publish last - PyPI uploads are irreversible (releases can be yanked + # but not re-uploaded under the same version). The `release` GitHub + # Environment's required reviewer acts as the human gate. + # + # Trusted Publishing: twine >= 5 picks up the GitHub OIDC token + # automatically when `id-token: write` is set and no + # username/password/token is provided. + - name: Publish to PyPI + run: python -m twine upload --skip-existing --non-interactive dist/* + + - name: Summary + env: + MODULE: ${{ steps.module.outputs.module }} + VERSION: ${{ steps.module.outputs.version }} + PACKAGE_NAME: ${{ steps.module.outputs.package_name }} + TAG: ${{ github.ref_name }} + run: | + { + echo "## ${MODULE} ${VERSION} published" + echo "" + echo "- [PyPI](https://pypi.org/project/${PACKAGE_NAME}/${VERSION}/)" + echo "- [Draft GitHub Release](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG})" + echo "" + echo "### Next step" + echo "Review the draft GitHub release and click **Publish release** to make it live." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..92efc3a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +Release notes for the `mixpanel` Python package will be added here starting +with the first release made under the standardized release process. + +For prior history, see [`CHANGES.txt`](./CHANGES.txt). diff --git a/README.md b/README.md index 35586b5..97b902d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # mixpanel-python +##### _May 13, 2026_ - [v5.1.0](https://github.com/mixpanel/mixpanel-python/releases/tag/v5.1.0) + [![PyPI](https://img.shields.io/pypi/v/mixpanel)](https://pypi.org/project/mixpanel) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mixpanel)](https://pypi.org/project/mixpanel) [![PyPI - Downloads](https://img.shields.io/pypi/dm/mixpanel)](https://pypi.org/project/mixpanel) diff --git a/openfeature-provider/CHANGELOG.md b/openfeature-provider/CHANGELOG.md new file mode 100644 index 0000000..673454c --- /dev/null +++ b/openfeature-provider/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +Release notes for the `mixpanel-openfeature` package will be added here +starting with the first release made under the standardized release process. diff --git a/openfeature-provider/README.md b/openfeature-provider/README.md index 2e45f54..e472f90 100644 --- a/openfeature-provider/README.md +++ b/openfeature-provider/README.md @@ -1,5 +1,7 @@ # Mixpanel Python OpenFeature Provider +##### _May 13, 2026_ - [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-python/releases/tag/openfeature/v0.1.0) + [![PyPI](https://img.shields.io/pypi/v/mixpanel-openfeature.svg)](https://pypi.org/project/mixpanel-openfeature/) [![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/mixpanel/mixpanel-python/blob/master/LICENSE)