name: Release Proposal

# This workflow creates a release proposal as a PR that requires approval from maintainers
# Triggered manually by maintainers when ready to prepare a release
on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to release (e.g., v2.8.0)'
        required: true
        type: string
      commit_hash:
        description: 'Commit hash to release from'
        required: true
        type: string

permissions:
  contents: read

jobs:
  create-proposal:
    name: Create Release Proposal
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
    
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit
      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0

      - name: Trim and validate inputs
        id: inputs
        run: |
          # Trim whitespace from inputs
          VERSION=$(echo "${{ inputs.version }}" | xargs)
          COMMIT_HASH=$(echo "${{ inputs.commit_hash }}" | xargs)
          
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT
          
          # Validate version format
          if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
            echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)"
            exit 1
          fi
          
          # Validate commit hash format
          if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then
            echo "Error: Commit hash must be a valid SHA (7-40 characters)"
            exit 1
          fi
          
          # Check if commit exists
          if ! git cat-file -e "$COMMIT_HASH"; then
            echo "Error: Commit $COMMIT_HASH does not exist"
            exit 1
          fi

      - name: Check if tag already exists
        run: |
          if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then
            echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists"
            exit 1
          fi

      - name: Check for existing proposal PR
        id: check_existing
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            const version = '${{ steps.inputs.outputs.version }}';
            
            // Search for existing open PRs with release-proposal label that match this version
            const openPRs = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'open',
              sort: 'updated',
              direction: 'desc'
            });
            
            const existingOpenPR = openPRs.data.find(pr => 
              pr.title.includes(version) && 
              pr.labels.some(label => label.name === 'release-proposal')
            );
            
            if (existingOpenPR) {
              const hasReleased = existingOpenPR.labels.some(label => label.name === 'released');
              const hasReleaseInProgress = existingOpenPR.labels.some(label => label.name === 'release-in-progress');
              
              if (hasReleased || hasReleaseInProgress) {
                core.setFailed(`A release for ${version} is already in progress or completed: ${existingOpenPR.html_url}`);
              } else {
                core.setFailed(`An open release proposal already exists for ${version}: ${existingOpenPR.html_url}\n\nPlease use the existing PR or close it first.`);
              }
              return;
            }
            
            // Check for closed PRs with this version that were cancelled
            const closedPRs = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'closed',
              sort: 'updated',
              direction: 'desc'
            });
            
            const cancelledPR = closedPRs.data.find(pr => 
              pr.title.includes(version) && 
              pr.labels.some(label => label.name === 'release-proposal') &&
              pr.labels.some(label => label.name === 'cancelled')
            );
            
            if (cancelledPR) {
              console.log(`Found previously cancelled proposal for ${version}: ${cancelledPR.html_url}`);
              console.log('Creating new proposal to replace cancelled one...');
            } else {
              console.log(`No existing proposal found for ${version}, proceeding...`);
            }

      - name: Generate changelog and create branch
        id: setup
        run: |
          VERSION="${{ steps.inputs.outputs.version }}"
          COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}"
          
          # Create a new branch for the release proposal
          BRANCH_NAME="release_proposal-$VERSION"
          git checkout -b "$BRANCH_NAME"
          
          # Calculate how many commits behind HEAD
          COMMITS_BEHIND=$(git rev-list --count ${COMMIT_HASH}..HEAD)
          
          if [ "$COMMITS_BEHIND" -eq 0 ]; then
            BEHIND_INFO="This is the latest commit (HEAD)"
          else
            BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**"
          fi
          
          echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT
          echo "behind_info=$BEHIND_INFO" >> $GITHUB_OUTPUT
          
          # Get the last tag
          LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
          
          if [ -z "$LAST_TAG" ]; then
            echo "No previous tag found, generating full changelog"
            COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH")
          else
            echo "Generating changelog since $LAST_TAG"
            COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH")
          fi
          
          # Store changelog for PR body
          CLEANSED_COMMITS=$(echo "$COMMITS" | sed 's/`/\\`/g')
          echo "changelog<<EOF" >> $GITHUB_OUTPUT
          echo "$CLEANSED_COMMITS" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
          
          # Create empty commit for the PR
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git commit --allow-empty -m "Release proposal for $VERSION"
          
          # Push the branch
          git push origin "$BRANCH_NAME"
          
          echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT

      - name: Create release proposal PR
        id: create_pr
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            const changelog = `${{ steps.setup.outputs.changelog }}`;
            
            const pr = await github.rest.pulls.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: `Release Proposal: ${{ steps.inputs.outputs.version }}`,
              head: '${{ steps.setup.outputs.branch_name }}',
              base: 'master',
              body: `## Release Proposal: ${{ steps.inputs.outputs.version }}
            
            **Target Commit:** \`${{ steps.inputs.outputs.commit_hash }}\`
            **Requested by:** @${{ github.actor }}
            **Commit Status:** ${{ steps.setup.outputs.behind_info }}
            
            This PR proposes creating release tag \`${{ steps.inputs.outputs.version }}\` at commit \`${{ steps.inputs.outputs.commit_hash }}\`.
            
            ### Approval Process
            
            This PR requires **approval from 2+ maintainers** before the tag can be created.
            
            ### What happens next?
            
            1. Maintainers review this proposal
            2. When 2+ maintainer approvals are received, an automated workflow will post tagging instructions
            3. A maintainer manually creates and pushes the signed tag
            4. The release workflow is triggered automatically by the tag push
            5. Upon release completion, this PR is closed and the branch is deleted
            
            ### Changes Since Last Release
            
            ${changelog}
            
            ### Release Checklist
            
            - [ ] All tests pass
            - [ ] Security review completed
            - [ ] Documentation updated
            - [ ] Breaking changes documented
            
            ---
            
            **Note:** Tag creation is manual and requires a signed tag from a maintainer.`,
              draft: true
            });
            
            // Add labels
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.data.number,
              labels: ['release-proposal', 'awaiting-approval']
            });
            
            console.log(`Created PR: ${pr.data.html_url}`);
            
            return { number: pr.data.number, url: pr.data.html_url };
          result-encoding: json

      - name: Post summary
        run: |
          echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY
          echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY
          echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY
