Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/modules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"analytics": {
"tag_prefix": "",
"composer_json": "composer.json",
"version_files": [],
"changelog": "CHANGELOG.md",
"readme": "README.md",
"package_name": "mixpanel/mixpanel-php"
}
}
87 changes: 87 additions & 0 deletions .github/scripts/generate-changelog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/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-)

# feat / fix: include whether bare, scoped to our module, or scoped to
# `all` (cross-cutting changes that appear in every module's changelog).
# chore: include only when explicitly scoped to our module or `all` —
# bare `chore:` is the convention for changes intentionally hidden from
# the changelog (release prep PRs, CI tweaks, lockfile bumps, internal
# docs).
if [[ "$MSG" =~ ^(feat|fix)(\((${MODULE}|all)\))?:\ (.+) ]]; then
TYPE="${BASH_REMATCH[1]}"
DESC="${BASH_REMATCH[4]}"
DESC=$(echo "$DESC" | sed -E "s|\(#([0-9]+)\)|([#\1](${SAFE_URL}/pull/\1))|g")
case "$TYPE" in
feat) FEATURES+=("$DESC") ;;
fix) FIXES+=("$DESC") ;;
esac
elif [[ "$MSG" =~ ^chore\((${MODULE}|all)\):\ (.+) ]]; then
DESC="${BASH_REMATCH[2]}"
DESC=$(echo "$DESC" | sed -E "s|\(#([0-9]+)\)|([#\1](${SAFE_URL}/pull/\1))|g")
CHORES+=("$DESC")
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
188 changes: 188 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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., 2.12.0 or 2.12.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 may be empty (e.g. mixpanel-php uses bare semver tags
# like `2.11.0`). The concatenation below still yields the bare
# version string in that case.
TAG_PREFIX=$(echo "$MODULE_CONFIG" | jq -r '.tag_prefix')
{
echo "tag=${TAG_PREFIX}${VERSION}"
echo "composer_json=$(echo "$MODULE_CONFIG" | jq -r '.composer_json')"
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"

# NOTE: composer.json intentionally has NO `version` field. Packagist
# reads the version from the git tag itself, and putting an explicit
# version in composer.json is actively discouraged because it goes
# stale. So the prepare workflow has no "bump composer.json version"
# step. The README header + CHANGELOG prepend below are the only file
# changes.

- 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 version header line.
# The `1,/pat/` address range bounds the substitution to lines from
# the start of the file through the first match, so a README that
# accidentally contains a second matching line is left untouched.
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 }}
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 "$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 }}
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
- Updates \`${CHANGELOG_FILE}\` with a new section since the last tag
- Updates \`${README}\` version header

Note: \`composer.json\` is intentionally untouched. Packagist reads the version from the git tag, and there is no \`version\` field in \`composer.json\` to bump.

### 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 validates the tag and creates a draft GitHub release. Packagist auto-syncs from its GitHub webhook on tag push, so there is no upload step.
3. Review the draft GitHub release and click **Publish release** to make it live.
EOF
)" \
--base master \
--head "$BRANCH"
Loading
Loading