Skip to content
Draft
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
18 changes: 18 additions & 0 deletions .github/modules.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
80 changes: 80 additions & 0 deletions .github/scripts/generate-changelog.sh
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions .github/workflows/pr-title-check.yml
Original file line number Diff line number Diff line change
@@ -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(<module>|all): description"
echo " fix(<module>|all): description"
echo " chore(<module>|all): description"
echo " release: description"
echo ""
echo "Valid scopes: ${MODULE_LIST//|/, }, all"
exit 1
238 changes: 238 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -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<<EOF"
echo "$MODULE_CONFIG" | jq -r '.version_files[]?'
echo "EOF"
echo "changelog=$(echo "$MODULE_CONFIG" | jq -r '.changelog')"
echo "readme=$(echo "$MODULE_CONFIG" | jq -r '.readme')"
echo "package_name=$(echo "$MODULE_CONFIG" | jq -r '.package_name')"
echo "branch=release/${MODULE}/${VERSION}"
} >> "$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 - <<PY
import sys
try:
import tomllib # py311+
except ModuleNotFoundError:
import tomli as tomllib
with open("${PYPROJECT_TOML}", "rb") as f:
data = tomllib.load(f)
project = data.get("project", {})
dynamic = project.get("dynamic", []) or []
print("yes" if "version" in dynamic else "no")
PY
)

if [ "$IS_DYNAMIC" = "yes" ]; then
echo "pyproject ${PYPROJECT_TOML} declares version dynamically; updating version_files."
if [ -z "${VERSION_FILES//[[:space:]]/}" ]; then
echo "::error::Module has dynamic version but no version_files entry in modules.json"
exit 1
fi
while IFS= read -r vf; do
[ -z "$vf" ] && continue
if [ ! -f "$vf" ]; then
echo "::error::version_files entry not found: $vf"
exit 1
fi
# Match either `__version__ = "X"` or `__version__ = 'X'`.
if ! grep -E "^__version__\s*=\s*['\"][^'\"]+['\"]" "$vf" >/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 <<EOF
## Release ${MODULE} ${VERSION}

This PR prepares the ${MODULE} module for release.

### Changes
- Bumps version to \`${VERSION}\` (in \`${PYPROJECT_TOML}\` and/or its referenced version file)
- Updates \`${CHANGELOG_FILE}\` with a new section since the last \`${TAG_PREFIX}*\` tag
- Updates \`${README}\` version header

### After merging
1. Push tag \`${TAG}\` from the merge commit on \`master\` to trigger the publish workflow:
\`\`\`
git checkout master && git pull
git tag ${TAG}
git push origin ${TAG}
\`\`\`
2. The publish workflow creates a draft GitHub release and publishes to PyPI via Trusted Publishing. Review and publish the GitHub release after the workflow finishes.
EOF
)" \
--base master \
--head "$BRANCH"
Loading
Loading