diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 000000000000..786a14b65ef8 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,214 @@ +name: commitlint + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - '12.x' # Current main development branch + - '*.x' # Version branches (10.x, 11.x, etc.) + - master # Legacy main branch support + +permissions: + contents: read + pull-requests: write + +jobs: + commitlint: + runs-on: ubuntu-latest + name: Validate Commit Messages (using Shell Script) + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Make commitlint script executable + run: chmod +x bin/commitlint.sh + + - name: Validate commit messages (Pull Request) + if: github.event_name == 'pull_request' + run: | + set -e + echo "🔍 Validating commit messages in pull request using shell script..." + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + + # Create a temporary directory for commit message files + mkdir -p /tmp/commit-msgs + + # Get commit count and check if any commits exist (using rev-list for performance) + COMMIT_COUNT=$(git rev-list --count ${BASE_SHA}..${HEAD_SHA}) + if [ "$COMMIT_COUNT" -eq 0 ]; then + echo "::warning::No commits found in pull request" + exit 0 + fi + + echo "Found $COMMIT_COUNT commits to validate" + + # Use a temporary file to track validation status across subshell + VALIDATION_STATUS_FILE="/tmp/validation-status" + echo "success" > "$VALIDATION_STATUS_FILE" + + # Set up cleanup trap + trap 'rm -f "$VALIDATION_STATUS_FILE" /tmp/commit-msgs/msg-* 2>/dev/null || true' EXIT + + # Get all commits in the PR and validate each one + git log --format="%H|%s" ${BASE_SHA}..${HEAD_SHA} --reverse | while IFS='|' read -r commit_sha commit_msg; do + # Handle malformed commit entries gracefully + if [ -z "$commit_sha" ] || [ -z "$commit_msg" ]; then + echo "::warning::Skipping malformed commit entry" + continue + fi + + # Skip special commits that don't need conventional format validation + if printf "%s" "$commit_msg" | grep -Eq "^(Merge|WIP|Revert|[0-9]+\.x|\[[0-9]+\.x\])"; then + echo "Skipping special commit: $commit_sha - $commit_msg" + continue + fi + + echo "Validating commit: $commit_sha" + printf "Message: %q\n" "$commit_msg" + + # Create a temporary file with the commit message (properly quoted) + TEMP_MSG_FILE="/tmp/commit-msgs/msg-${commit_sha}" + printf "%s" "$commit_msg" > "$TEMP_MSG_FILE" + + # Run the commitlint script + if ! ./bin/commitlint.sh "$TEMP_MSG_FILE"; then + printf "::error::Commit %s failed validation: %q\n" "$commit_sha" "$commit_msg" + echo "failed" > "$VALIDATION_STATUS_FILE" + fi + + rm -f "$TEMP_MSG_FILE" + done + + # Check if any validation failed + VALIDATION_STATUS=$(cat "$VALIDATION_STATUS_FILE") + if [ "$VALIDATION_STATUS" = "failed" ]; then + echo "::error::One or more commit messages failed validation" + exit 1 + fi + + - name: Validate commit message (Push) + if: github.event_name == 'push' + run: | + set -e + echo "🔍 Validating latest commit message using shell script..." + + # Set up cleanup trap + TEMP_MSG_FILE="/tmp/latest-commit-msg" + trap 'rm -f "$TEMP_MSG_FILE" 2>/dev/null || true' EXIT + + # Get the latest commit message + COMMIT_MSG=$(git log -1 --pretty=format:"%s") + printf "Validating: %q\n" "$COMMIT_MSG" + + # Check if commit message is empty + if [ -z "$COMMIT_MSG" ]; then + echo "::error::Empty commit message found" + exit 1 + fi + + # Skip special commits that don't need conventional format validation + if printf "%s" "$COMMIT_MSG" | grep -Eq "^(Merge|WIP|Revert)"; then + echo "Skipping special commit: $COMMIT_MSG" + exit 0 + fi + + # Create a temporary file with the commit message (properly quoted) + printf "%s" "$COMMIT_MSG" > "$TEMP_MSG_FILE" + + # Run the commitlint script + if ! ./bin/commitlint.sh "$TEMP_MSG_FILE"; then + printf "::error::Latest commit message failed validation: %q\n" "$COMMIT_MSG" + exit 1 + fi + + - name: Comment on PR (Failure) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comment = `## ❌ Commit Message Validation Failed + + One or more commit messages in this pull request do not follow the [Conventional Commits](https://www.conventionalcommits.org/) format. + + ### Expected format: + \`\`\` + type(scope): description + \`\`\` + + ### Valid types: + - **feat**: A new feature + - **fix**: A bug fix + - **docs**: Documentation only changes + - **style**: Changes that do not affect the meaning of the code + - **refactor**: A code change that neither fixes a bug nor adds a feature + - **test**: Adding missing tests or correcting existing tests + - **chore**: Changes to the build process or auxiliary tools + - **perf**: A code change that improves performance + - **ci**: Changes to CI configuration files and scripts + - **build**: Changes that affect the build system + - **revert**: Reverts a previous commit + + ### Special commits (automatically allowed): + - Commits starting with **Merge**, **WIP**, or **Revert** are automatically skipped + + ### Examples: + - \`feat(auth): add user authentication\` + - \`fix(api): resolve validation error in user endpoint\` + - \`docs: update API documentation\` + + Please update your commit messages to follow this format. You can use \`git commit --amend\` for the latest commit or \`git rebase -i\` for multiple commits. + + For more information, see: [Laravel Contributions Guide](https://laravel.com/docs/12.x/contributions#semantic-commits) + + --- + *This validation uses the same shell script as your local git hook (\`bin/commitlint.sh\`) to ensure consistency.*`; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: comment + }); + + - name: Comment on PR (Success) + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comment = `## ✅ Commit Message Validation Passed + + All commit messages in this pull request follow the [Conventional Commits](https://www.conventionalcommits.org/) format. Great work! 🎉 + + *Validated using the shell script (\`bin/commitlint.sh\`) for consistency with local git hooks.*`; + + // Check if we already commented on this PR to avoid spam + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number + }); + + const existingComment = comments.data.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Commit Message Validation') + ); + + if (!existingComment) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: comment + }); + } diff --git a/bin/commitlint.sh b/bin/commitlint.sh new file mode 100644 index 000000000000..db4c1aaf2ff2 --- /dev/null +++ b/bin/commitlint.sh @@ -0,0 +1,85 @@ +#!/bin/sh +# commitlint.sh - Enforce Conventional Commits in commit messages +set -e + +# Set up cleanup trap for any temporary files +trap 'rm -f /tmp/commitlint-* 2>/dev/null || true' EXIT + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "${YELLOW}🔍 Validating commit message format...${NC}" + +# Check if this is a merge commit +if [ -f .git/MERGE_HEAD ]; then + echo "${GREEN}✅ Merge commit detected, skipping validation${NC}" + exit 0 +fi + +# Read the commit message +COMMIT_MSG_FILE="$1" +if [ -z "$COMMIT_MSG_FILE" ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ ! -f "$COMMIT_MSG_FILE" ]; then + echo "${RED}❌ Commit message file not found: $COMMIT_MSG_FILE${NC}" + exit 1 +fi + +COMMIT_MSG=$(head -n1 "$COMMIT_MSG_FILE") + +# Skip empty commits +if [ -z "$COMMIT_MSG" ] || [ "$COMMIT_MSG" = "" ]; then + echo "${RED}❌ Empty commit message${NC}" + exit 1 +fi + +# Skip special commits that don't need conventional format validation +# Also skip versioned commits like 10.x or [10.x] (to match workflow logic) +if printf "%s" "$COMMIT_MSG" | grep -Eq "^(Merge|WIP|Revert|[0-9]+\\.x|\\[[0-9]+\\.x\\])"; then + echo "${GREEN}✅ Special commit type detected, skipping conventional format validation${NC}" + exit 0 +fi + +# Conventional Commits regex (type[optional scope]: subject) +CONVENTIONAL_REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-zA-Z0-9_-]+\))?: .{1,}' + +# Log the commit message for debugging (safely quoted) +printf "${YELLOW}Checking message: %s${NC}\n" "$COMMIT_MSG" + +if printf "%s" "$COMMIT_MSG" | grep -Eq "$CONVENTIONAL_REGEX"; then + echo "${GREEN}✅ Commit message format is valid${NC}" + exit 0 +else + echo "" + echo "${RED}❌ Commit message does not follow Conventional Commits format!${NC}" + echo "" + echo "${YELLOW}Expected format:${NC}" + echo " ${GREEN}type(scope): description${NC}" + echo "" + echo "${YELLOW}Valid types:${NC}" + echo " feat: A new feature" + echo " fix: A bug fix" + echo " docs: Documentation only changes" + echo " style: Changes that do not affect the meaning of the code" + echo " refactor: A code change that neither fixes a bug nor adds a feature" + echo " test: Adding missing tests or correcting existing tests" + echo " chore: Changes to the build process or auxiliary tools" + echo " perf: A code change that improves performance" + echo " ci: Changes to CI configuration files and scripts" + echo " build: Changes that affect the build system" + echo " revert: Reverts a previous commit" + echo "" + echo "${YELLOW}Examples:${NC}" + echo " ${GREEN}feat(auth): add user authentication${NC}" + echo " ${GREEN}fix(api): resolve validation error in user endpoint${NC}" + echo " ${GREEN}docs: update API documentation${NC}" + echo "" + echo "See: https://laravel.com/docs/12.x/contributions#semantic-commits and https://www.conventionalcommits.org/en/v1.0.0/" + exit 1 +fi