diff --git a/.github/modules.json b/.github/modules.json new file mode 100644 index 0000000..f1386ea --- /dev/null +++ b/.github/modules.json @@ -0,0 +1,18 @@ +{ + "analytics": { + "tag_prefix": "v", + "gemspec": "mixpanel-ruby.gemspec", + "version_file": "lib/mixpanel-ruby/version.rb", + "changelog": "CHANGELOG.md", + "readme": "Readme.rdoc", + "package_name": "mixpanel-ruby" + }, + "openfeature": { + "tag_prefix": "openfeature/v", + "gemspec": "openfeature-provider/mixpanel-ruby-openfeature.gemspec", + "version_file": "openfeature-provider/lib/mixpanel/openfeature/version.rb", + "changelog": "openfeature-provider/CHANGELOG.md", + "readme": "openfeature-provider/README.md", + "package_name": "mixpanel-ruby-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..be45ace --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,189 @@ +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., 3.2.0 or 0.2.0)' + 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 "gemspec=$(echo "$MODULE_CONFIG" | jq -r '.gemspec')" + echo "version_file=$(echo "$MODULE_CONFIG" | jq -r '.version_file')" + 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 ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "::error::Tag $TAG already exists on origin" + 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 in version.rb + env: + VERSION: ${{ inputs.version }} + VERSION_FILE: ${{ steps.config.outputs.version_file }} + run: | + sed -i -E "s/VERSION = .*/VERSION = '${VERSION}'/" "$VERSION_FILE" + echo "Updated $VERSION_FILE:" + grep -n "VERSION" "$VERSION_FILE" || true + + - 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") + # Replace the first matching version-header line. The 1,/pat/ address + # range bounds the substitution so multi-version READMEs aren't + # trampled. Works for both Markdown and rdoc files (rdoc passes + # unrecognized lines through as text). + 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 + 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 }} + VERSION_FILE: ${{ steps.config.outputs.version_file }} + CHANGELOG_FILE: ${{ steps.config.outputs.changelog }} + README: ${{ steps.config.outputs.readme }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "$VERSION_FILE" "$CHANGELOG_FILE" "$README" + 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 }} + VERSION_FILE: ${{ steps.config.outputs.version_file }} + 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'" + + - 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 version_file matches tag + env: + VERSION: ${{ steps.module.outputs.version }} + VERSION_FILE: ${{ steps.module.outputs.version_file }} + run: | + FILE_VERSION=$(grep -E "^[[:space:]]*VERSION[[:space:]]*=" "$VERSION_FILE" \ + | head -1 \ + | sed -E "s/.*VERSION[[:space:]]*=[[:space:]]*['\"]([^'\"]+)['\"].*/\1/") + if [ -z "$FILE_VERSION" ]; then + echo "::error::Could not parse VERSION from $VERSION_FILE" + exit 1 + fi + if [ "$VERSION" != "$FILE_VERSION" ]; then + echo "::error::Tag version '$VERSION' does not match $VERSION_FILE version '$FILE_VERSION'" + exit 1 + fi + echo "Version confirmed: $FILE_VERSION" + + - name: Set up Ruby + uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1.269.0 + with: + ruby-version: '3.3' + bundler-cache: false + + - name: Install dependencies + env: + GEMSPEC: ${{ steps.module.outputs.gemspec }} + run: | + GEM_DIR=$(dirname "$GEMSPEC") + if [ -f "$GEM_DIR/Gemfile" ]; then + (cd "$GEM_DIR" && bundle install) + else + bundle install + fi + + - name: Run tests + env: + MODULE: ${{ steps.module.outputs.module }} + GEMSPEC: ${{ steps.module.outputs.gemspec }} + run: | + GEM_DIR=$(dirname "$GEMSPEC") + if [ -f "$GEM_DIR/Gemfile" ] && [ "$GEM_DIR" != "." ]; then + (cd "$GEM_DIR" && bundle exec rspec) + else + bundle exec rake + fi + + - name: Build gem + id: build + env: + GEMSPEC: ${{ steps.module.outputs.gemspec }} + run: | + GEM_DIR=$(dirname "$GEMSPEC") + GEMSPEC_BASENAME=$(basename "$GEMSPEC") + if [ "$GEM_DIR" = "." ]; then + gem build "$GEMSPEC_BASENAME" + GEM_FILE=$(ls -t *.gem | head -1) + echo "gem_path=${GEM_FILE}" >> "$GITHUB_OUTPUT" + else + (cd "$GEM_DIR" && gem build "$GEMSPEC_BASENAME") + GEM_FILE=$(ls -t "$GEM_DIR"/*.gem | head -1) + echo "gem_path=${GEM_FILE}" >> "$GITHUB_OUTPUT" + fi + echo "Built: $(ls -la "${GEM_FILE}")" + + - name: Extract changelog section for tag + env: + TAG: ${{ github.ref_name }} + CHANGELOG: ${{ steps.module.outputs.changelog }} + run: | + # Extract this tag's section from CHANGELOG.md via a sed range. + # The address `\@^## \[TAG\]@,\@^## \[@` selects from the version + # header through the next `## [` line; the inner block deletes + # both markers and prints the body. sed range patterns don't test + # the end pattern against the start line, so the start doesn't + # self-terminate. `\@...@` switches the address delimiter from + # `/` to `@` so tags containing `/` (e.g. `openfeature/v0.1.0`) + # don't conflict with the default `/`. Mirrors the deployed + # mixpanel-android extraction logic. Falls back to a placeholder + # if the section is missing or empty. + sed -n '\@^## \['"${TAG}"'\]@,\@^## \[@{\@^## \['"${TAG}"'\]@d;\@^## \[@d;p;}' "$CHANGELOG" 2>/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 + + - name: Configure RubyGems trusted publishing credentials + uses: rubygems/configure-rubygems-credentials@762a4b77c3300434bb57c7ce80b20e36231927aa # v2.0.0 + + # Publish last - RubyGems uploads are effectively irreversible (yank is + # restricted). The draft GitHub release + `release` environment reviewer + # acts as the human gate. + - name: Publish gem to RubyGems + env: + GEM_PATH: ${{ steps.build.outputs.gem_path }} + run: | + gem push "$GEM_PATH" + + - 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 "- [RubyGems](https://rubygems.org/gems/${PACKAGE_NAME}/versions/${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..8727123 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [v3.1.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.1.0) (2026-05-13) + +Initial entry for the standardized release process. See `Readme.rdoc` for prior version history. diff --git a/Readme.rdoc b/Readme.rdoc index 43f5502..f2b3912 100644 --- a/Readme.rdoc +++ b/Readme.rdoc @@ -1,5 +1,7 @@ = mixpanel-ruby: The official Mixpanel Ruby library +##### _May 13, 2026_ - [v3.1.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/v3.1.0) + mixpanel-ruby is a library for tracking events and sending \Mixpanel profile updates to \Mixpanel from your ruby applications. diff --git a/openfeature-provider/CHANGELOG.md b/openfeature-provider/CHANGELOG.md new file mode 100644 index 0000000..377b745 --- /dev/null +++ b/openfeature-provider/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/tree/openfeature/v0.1.0) (2026-05-13) + +Initial release of the Mixpanel OpenFeature provider for Ruby. diff --git a/openfeature-provider/README.md b/openfeature-provider/README.md index 503c778..8a833c7 100644 --- a/openfeature-provider/README.md +++ b/openfeature-provider/README.md @@ -1,5 +1,7 @@ # mixpanel-ruby-openfeature +##### _May 13, 2026_ - [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/openfeature/v0.1.0) + [![Gem Version](https://img.shields.io/gem/v/mixpanel-ruby-openfeature.svg)](https://rubygems.org/gems/mixpanel-ruby-openfeature) [![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/mixpanel/mixpanel-ruby/blob/master/LICENSE) diff --git a/openfeature-provider/lib/mixpanel/openfeature.rb b/openfeature-provider/lib/mixpanel/openfeature.rb index 5fb6ba2..a004705 100644 --- a/openfeature-provider/lib/mixpanel/openfeature.rb +++ b/openfeature-provider/lib/mixpanel/openfeature.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true +require_relative 'openfeature/version' require_relative 'openfeature/provider' diff --git a/openfeature-provider/lib/mixpanel/openfeature/version.rb b/openfeature-provider/lib/mixpanel/openfeature/version.rb new file mode 100644 index 0000000..ab13e2c --- /dev/null +++ b/openfeature-provider/lib/mixpanel/openfeature/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Mixpanel + module OpenFeature + VERSION = '0.1.0' + end +end diff --git a/openfeature-provider/mixpanel-ruby-openfeature.gemspec b/openfeature-provider/mixpanel-ruby-openfeature.gemspec index 9a496f3..79cdb10 100644 --- a/openfeature-provider/mixpanel-ruby-openfeature.gemspec +++ b/openfeature-provider/mixpanel-ruby-openfeature.gemspec @@ -1,8 +1,10 @@ # frozen_string_literal: true +require File.join(File.dirname(__FILE__), 'lib/mixpanel/openfeature/version.rb') + Gem::Specification.new do |spec| spec.name = 'mixpanel-ruby-openfeature' - spec.version = '0.1.0' + spec.version = Mixpanel::OpenFeature::VERSION spec.authors = ['Mixpanel'] spec.email = 'support@mixpanel.com' spec.summary = 'OpenFeature provider for Mixpanel feature flags'