ccval 0.4.0

A validator for conventional commits
name: 'ccval - Conventional Commits Validator'
description: 'Validate commit messages using the Conventional Commits format'
author: 'Andrey Fomin'
branding:
  icon: 'check-circle'
  color: 'green'

inputs:
  config:
    description: 'Path to custom config file (auto-discovered if not set)'
    required: false
  preset:
    description: 'Built-in preset to apply (default or strict)'
    required: false
  git-args:
    description: 'Override default git log arguments (auto-detected if not set)'
    required: false
  max-commits:
    description: 'Maximum number of commits to validate before skipping with a warning'
    required: false
    default: '100'

runs:
  using: composite
  steps:
    - name: Validate commits
      shell: bash
      env:
        GITHUB_EVENT_NAME: ${{ github.event_name }}
        GITHUB_EVENT_DELETED: ${{ github.event.deleted }}
        GITHUB_EVENT_BEFORE: ${{ github.event.before }}
        GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
        GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        GITHUB_SHA: ${{ github.sha }}
        GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
        INPUT_CONFIG: ${{ inputs.config }}
        INPUT_PRESET: ${{ inputs.preset }}
        INPUT_GIT_ARGS: ${{ inputs.git-args }}
        INPUT_MAX_COMMITS: ${{ inputs.max-commits }}
      run: |
        push_event_commit_count() {
          python3 -c 'import json, os; e=json.load(open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8")); n=e.get("distinct_size") or e.get("size") or len(e.get("commits") or []); print(n)'
        }

        selected_commit_count() {
          git log --format=%H "${GIT_ARGS[@]}" | wc -l | tr -d '[:space:]'
        }

        validate_max_commits_input() {
          case "$INPUT_MAX_COMMITS" in
            ''|*[!0-9]*)
              echo "::error::max-commits must be a positive integer." >&2
              exit 1
              ;;
          esac

          INPUT_MAX_COMMITS=$((10#$INPUT_MAX_COMMITS))
          if [ "$INPUT_MAX_COMMITS" -le 0 ]; then
            echo "::error::max-commits must be greater than zero." >&2
            exit 1
          fi
        }

        resolve_default_branch_ref() {
          if git rev-parse --verify "origin/$GITHUB_DEFAULT_BRANCH^{commit}" >/dev/null 2>&1; then
            printf '%s\n' "origin/$GITHUB_DEFAULT_BRANCH"
            return 0
          fi

          if git rev-parse --verify "$GITHUB_DEFAULT_BRANCH^{commit}" >/dev/null 2>&1; then
            printf '%s\n' "$GITHUB_DEFAULT_BRANCH"
            return 0
          fi

          return 1
        }

        set_push_git_args() {
          local head_history_count
          local default_branch_ref
          local base_sha
          local range_start
          local range_start_parent

          if [ -n "$GITHUB_EVENT_BEFORE" ] && [ "$GITHUB_EVENT_BEFORE" != "0000000000000000000000000000000000000000" ]; then
            if git rev-parse --verify "$GITHUB_EVENT_BEFORE^{commit}" >/dev/null 2>&1; then
              GIT_ARGS=("$GITHUB_EVENT_BEFORE..$GITHUB_SHA" "--no-merges")
              return 0
            fi

            echo "Warning: Previous commit $GITHUB_EVENT_BEFORE is not available in the local checkout." >&2
            echo "Warning: Falling back to merge-base detection. Consider increasing checkout.fetch-depth (or setting it to 0) so the full pushed range can be validated." >&2
          fi

          head_history_count=$(git rev-list --count "$GITHUB_SHA")
          if [ "$head_history_count" = "$PUSH_EVENT_COMMIT_COUNT" ]; then
            range_start=$(git rev-list --max-count="$PUSH_EVENT_COMMIT_COUNT" "$GITHUB_SHA" | tail -n 1 || true)
            range_start_parent=$(git rev-parse "${range_start}^" 2>/dev/null || true)
            if [ -n "$range_start_parent" ]; then
              GIT_ARGS=("$range_start_parent..$GITHUB_SHA" "--no-merges")
              return 0
            elif [ "$PUSH_EVENT_COMMIT_COUNT" -eq 1 ]; then
              GIT_ARGS=("$GITHUB_SHA" "--no-merges")
              return 0
            fi
          fi

          default_branch_ref=$(resolve_default_branch_ref || true)
          if [ -n "$default_branch_ref" ]; then
            base_sha=$(git merge-base "$default_branch_ref" "$GITHUB_SHA" || true)
          else
            base_sha=""
          fi

          if [ -n "$base_sha" ] && [ "$base_sha" != "$GITHUB_SHA" ]; then
            GIT_ARGS=("$base_sha..$GITHUB_SHA" "--no-merges")
            return 0
          fi

          echo "::error::Unable to determine the pushed commit range from the local checkout." >&2
          echo "::error::Use actions/checkout with enough history (for example fetch-depth: 0) or provide custom git-args." >&2
          exit 1
        }

        validate_max_commits_input

        if [ "$GITHUB_EVENT_NAME" = "push" ] && [ "$GITHUB_EVENT_DELETED" = "true" ]; then
          echo "::notice::Skipping validation for deleted ref push event."
          exit 0
        fi

        if [ "$GITHUB_EVENT_NAME" = "push" ]; then
          PUSH_EVENT_COMMIT_COUNT=$(push_event_commit_count)
          if [ "$PUSH_EVENT_COMMIT_COUNT" -eq 0 ]; then
            echo "::notice::Skipping validation for push events with zero commits."
            exit 0
          fi
          if [ "$PUSH_EVENT_COMMIT_COUNT" -gt "$INPUT_MAX_COMMITS" ]; then
            echo "::warning::Skipping validation because the push event contains $PUSH_EVENT_COMMIT_COUNT commits, which exceeds max-commits=$INPUT_MAX_COMMITS." >&2
            exit 0
          fi
        elif [ "$GITHUB_EVENT_NAME" != "pull_request" ]; then
          echo "::error::This action supports only push and pull_request events." >&2
          exit 1
        fi

        # Auto-detect git args
        if [ -n "$INPUT_GIT_ARGS" ]; then
          read -r -a GIT_ARGS <<< "$INPUT_GIT_ARGS"
        elif [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
          GIT_ARGS=("$GITHUB_BASE_SHA..$GITHUB_HEAD_SHA" "--no-merges")
        elif [ "$GITHUB_EVENT_NAME" = "push" ]; then
          set_push_git_args
        fi

        COMMIT_COUNT=$(selected_commit_count)
        if [ "$COMMIT_COUNT" -gt "$INPUT_MAX_COMMITS" ]; then
          echo "::warning::Skipping validation because $COMMIT_COUNT commits match the selected range, which exceeds max-commits=$INPUT_MAX_COMMITS." >&2
          exit 0
        fi

        # Auto-discover config
        CONFIG_ARGS=()
        if [ -n "$INPUT_CONFIG" ]; then
          CONFIG_ARGS=(-c "$INPUT_CONFIG")
        elif [ -f "conventional-commits.yaml" ]; then
          CONFIG_ARGS=(-c conventional-commits.yaml)
        elif [ -f ".github/conventional-commits.yaml" ]; then
          CONFIG_ARGS=(-c .github/conventional-commits.yaml)
        fi

        PRESET_ARGS=()
        if [ -n "$INPUT_PRESET" ]; then
          PRESET_ARGS=(-p "$INPUT_PRESET")
        fi

        # Run ccval
        docker run --pull always --rm -v "$PWD:/workspace" -w /workspace \
          andreyfomin/ccval:0 --trust-repo "${CONFIG_ARGS[@]}" "${PRESET_ARGS[@]}" -- "${GIT_ARGS[@]}"