diff --git a/.github/workflows/trademark-cla-approval.yml b/.github/workflows/trademark-cla-approval.yml new file mode 100644 index 00000000000..435756f4c51 --- /dev/null +++ b/.github/workflows/trademark-cla-approval.yml @@ -0,0 +1,219 @@ +name: CLA Approval Handler + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to approve CLA for' + required: true + type: string + pull_request: + types: [labeled] + +permissions: write-all + +jobs: + process-cla-approval: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'cla-signed' + + steps: + + - name: Generate Token + id: generate-token + continue-on-error: true + uses: actions/create-github-app-token@v1 + with: + app-id: "${{ secrets.WORKFLOW_AUTH_PUBLIC_APP_ID }}" + private-key: "${{ secrets.WORKFLOW_AUTH_PUBLIC_PRIVATE_KEY }}" + + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.generate-token.outputs.token || secrets.GITHUB_TOKEN }} + + - name: Process CLA approval + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token || secrets.GITHUB_TOKEN }} + script: | + let prNumber; + + // Determine PR number + if (context.eventName === 'workflow_dispatch') { + prNumber = parseInt('${{ github.event.inputs.pr_number }}'); + } else if (context.eventName === 'pull_request') { + prNumber = context.payload.pull_request.number; + } else { + return; + } + + // Get PR details + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + + // Check if the person triggering has the right permissions + try { + const { data: collaboration } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + + // Only admin, maintain, or write permissions can approve CLA + const isAuthorized = ['admin', 'maintain', 'write'].includes(collaboration.permission); + + if (!isAuthorized) { + + // If this was a label event, remove the label + if (context.eventName !== 'workflow_dispatch') { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'cla-signed' + }); + } + + // Add a comment explaining why the action was blocked + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `@${context.actor} Only repository maintainers can approve CLAs. ${context.eventName !== 'workflow_dispatch' ? 'The label has been removed.' : ''}` + }); + + return; + } + + // Check if PR has cla-required label + const { data: labels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const hasClaNeeded = labels.some(label => label.name === 'cla-required'); + + if (!hasClaNeeded) { + return; + } + + // Ensure cla-signed label is present + const hasClaSigned = labels.some(label => label.name === 'cla-signed'); + if (!hasClaSigned) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['cla-signed'] + }); + } + + // Authorized - proceed with approval + + // Remove the blocking label + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'cla-required' + }); + } catch (e) { + // Label not found or already removed + } + + // Store the manual approval information + core.setOutput('pr_number', prNumber); + core.setOutput('pr_author', pr.user.login); + core.setOutput('approved_by', context.actor); + + // Check if confirmation comment already exists + const comments = await github.rest.issues.listComments({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const confirmationExists = comments.data.some(comment => + (comment.user.login === 'github-actions[bot]' || comment.user.type === 'Bot') && + comment.body.includes('CLA Agreement Confirmed') + ); + + if (!confirmationExists) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `## CLA Agreement Confirmed + + The trademark license agreement has been approved for @${pr.user.login}. + + **Status:** Approved + **Date:** ${new Date().toISOString()} + **Approved by:** @${context.actor} + **Method:** ${context.eventName === 'workflow_dispatch' ? 'Manual approval' : 'Label approval'} + + This PR is now unblocked and can proceed with normal review!` + }); + } + + } catch (error) { + throw error; + } + + - name: Record manual CLA approval + if: steps.process-cla-approval.outputs.pr_number + run: | + # Ensure signatures file exists + if [ ! -f "cla-signatures.json" ]; then + echo '{"signatures": []}' > cla-signatures.json + fi + + # Extract approval details from previous step outputs + USERNAME="${{ steps.process-cla-approval.outputs.pr_author }}" + DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + PR_NUMBER="${{ steps.process-cla-approval.outputs.pr_number }}" + APPROVED_BY="${{ steps.process-cla-approval.outputs.approved_by }}" + + echo "Recording manual CLA approval:" + echo " Username: $USERNAME" + echo " PR Number: $PR_NUMBER" + echo " Approved by: $APPROVED_BY" + echo " Date: $DATE" + + # Check if this user already has a signature for this PR + EXISTING_SIGNATURE=$(jq --arg user "$USERNAME" --arg pr "$PR_NUMBER" '.signatures[] | select(.username == $user and .pr_number == ($pr | tonumber))' cla-signatures.json) + + if [ -z "$EXISTING_SIGNATURE" ]; then + # Add new signature entry + jq --arg user "$USERNAME" \ + --arg date "$DATE" \ + --arg pr "$PR_NUMBER" \ + --arg approved_by "$APPROVED_BY" \ + '.signatures += [{ + "username": $user, + "date": $date, + "pr_number": ($pr | tonumber), + "approved_by": $approved_by + }]' cla-signatures.json > tmp.json && mv tmp.json cla-signatures.json + + echo "New CLA approval signature added" + else + echo "Signature already exists for this user and PR" + fi + + # Commit the updated file + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add cla-signatures.json + git commit -m "Add manual CLA approval for @$USERNAME (PR #$PR_NUMBER) by @$APPROVED_BY" || echo "No changes to commit" + git push + + echo "Manual CLA approval recorded successfully" diff --git a/.github/workflows/trademark-cla-notice.yml b/.github/workflows/trademark-cla-notice.yml new file mode 100644 index 00000000000..1ff579b5267 --- /dev/null +++ b/.github/workflows/trademark-cla-notice.yml @@ -0,0 +1,227 @@ +name: Trademark CLA Notice + +on: + pull_request: + types: [opened, edited, synchronize] + +# Set repository-level permissions +permissions: write-all + +jobs: + enforce-docs-cla: + runs-on: ubuntu-latest + # Job-level permissions (inherits from above but can be more restrictive) + permissions: write-all + + steps: + - name: Generate Token + id: generate-token + continue-on-error: true + uses: actions/create-github-app-token@v1 + with: + app-id: "${{ secrets.WORKFLOW_AUTH_PUBLIC_APP_ID }}" + private-key: "${{ secrets.WORKFLOW_AUTH_PUBLIC_PRIVATE_KEY }}" + + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Use the GitHub App token if available, otherwise fallback to GITHUB_TOKEN + token: ${{ steps.generate-token.outputs.token || secrets.GITHUB_TOKEN }} + + - name: Check if docs changed + id: docs-changed + if: github.event_name == 'pull_request' + run: | + changed_files=$(git diff --name-only ${{ github.event.pull_request.base.sha}} ${{ github.event.pull_request.head.sha}}) + + if echo "$changed_files" | grep -E '^docs/integrations/|^docs/static/' > /dev/null; then + echo "docs_changed=true" >> $GITHUB_OUTPUT + echo "requires_cla=true" >> $GITHUB_OUTPUT + else + echo "docs_changed=false" >> $GITHUB_OUTPUT + echo "requires_cla=false" >> $GITHUB_OUTPUT + fi + + - name: Get PR info for comment events + id: pr-info + if: github.event_name == 'issue_comment' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_PAT || secrets.GITHUB_TOKEN }} + script: | + if (context.payload.issue.pull_request) { + const prNumber = context.payload.issue.number; + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const hasDocsChanges = files.some(file => + file.filename.startsWith('docs/integrations/') || + file.filename.startsWith('static/images/') + ); + + // Check if PR author is a collaborator (safer alternative to org membership) + let isClickHouseMember = false; + try { + const { data: collaboration } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: pr.user.login + }); + + // Consider admin, maintain, or write permissions as "member" + isClickHouseMember = ['admin', 'maintain', 'write'].includes(collaboration.permission); + } catch (error) { + console.log(`Could not determine collaboration status for ${pr.user.login}: ${error.message}`); + isClickHouseMember = false; + } + + core.setOutput('pr_number', prNumber); + core.setOutput('has_docs_changes', hasDocsChanges); + core.setOutput('pr_head_sha', pr.head.sha); + core.setOutput('pr_author', pr.user.login); + core.setOutput('isClickHouseMember', isClickHouseMember); + + return { prNumber, hasDocsChanges, headSha: pr.head.sha, author: pr.user.login, isClickHouseMember }; + } + + return null; + + - name: Post CLA comment and block merge + if: github.event_name == 'pull_request' && steps.docs-changed.outputs.requires_cla == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token || secrets.GITHUB_TOKEN }} + script: | + let prNumber, prAuthor; + + if (context.eventName == 'pull_request') { + prNumber = context.issue.number; + prAuthor = '${{ github.event.pull_request.user.login }}'; + } + + if (!prNumber || !prAuthor) { + return; + } + + try { + // Check if user is in @ClickHouse/everyone team + let isClickHouseMember = false; + try { + await github.rest.teams.getMembershipForUserInOrg({ + org: 'ClickHouse', + team_slug: 'everyone', + username: prAuthor + }); + isClickHouseMember = true; + } catch (error) { + // User is not in the team or team doesn't exist + isClickHouseMember = false; + } + + // Skip CLA requirement for ClickHouse team members + if (isClickHouseMember) { + return; + } + + // Check if CLA comment already exists + const comments = await github.rest.issues.listComments({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const existingClaComment = comments.data.find(comment => + (comment.user.login === 'github-actions[bot]' || comment.user.type === 'Bot') && + comment.body.includes('CLA Agreement Required - MERGE BLOCKED') + ); + + if (!existingClaComment && context.eventName === 'pull_request') { + const claText = '# Trademark License Addendum\n\n' + + 'Merging of this pull request is temporarily blocked. Please \n' + + 'read and agree to the Trademark License Addendum below to \n' + + 'unblock merging of this pull request.\n\n' + + '
\n' + + 'Click to see Trademark License Addendum\n\n' + + 'This Trademark License Addendum ("Addendum") shall, if You have opted \n' + + 'in by replying to the comment that references this Addendum that you\n' + + 'have read and agree to theContributor License Agreement Addendum,\n' + + 'supplement the terms of the Individual Contributor License Agreement\n' + + 'between You and the Company ("Agreement"). Capitalized terms not\n' + + 'defined herein shall have the meanings ascribed to them in the \n' + + 'Agreement.\n\n' + + '1. Grant of Trademark License. Subject to the terms and conditions \n' + + 'of this Addendum, You grant to the Company a revocable, worldwide,\n' + + 'non-exclusive, non-sublicensable (except for contractors or agents\n' + + 'acting on the Company\'s behalf, for whose compliance with this \n' + + 'Addendum Company agrees to be responsible), royalty-free, and non-transferable\n' + + 'right to display the Partner Trademarks, solely for the purpose of\n' + + 'marketing and promoting your Contribution (i) on the Company\'s website\n' + + 'and in any Company in-product integrations page; and (ii) in marketing, sales,\n' + + 'and product materials for Company products. "Partner Trademarks" mean Your \n' + + 'employer\'s name and any employer brand features (e.g., logo) You submit now or \n' + + 'in the future to the Company in connection with your Contributions.\n' + + '2. Legal authority. You represent that you are legally entitled to\n' + + 'grant the above license. If your employer(s) has rights to \n' + + 'intellectual property in the Partner Trademarks, you represent that\n' + + 'you have received permission to grant the above license on behalf \n' + + 'of that employer, or that your employer has executed a separate \n' + + 'agreement with the Company concerning the subject matter of this \n' + + 'Addendum.\n' + + '3. Conditions. The license in Section 1 is subject to the following\n' + + 'conditions:\n' + + 'i. The Company shall use the Partner Trademarks in accordance with\n' + + ' any reasonable trademark usage guidelines You provide;\n' + + 'ii. You may revoke this license at any time upon thirty (30) days\'\n' + + ' written notice to the Company, after which the Company shall use\n' + + ' commercially reasonable efforts to cease all further public\n' + + ' use of the Partner Trademarks (but may maintain uses in archived\n' + + ' web pages, changelogs, and previously distributed materials).\n' + + 'iii. The Company acknowledges and agrees that it does not own the \n' + + ' Partner Trademarks and that all goodwill derived from the use \n' + + ' of the Partner Trademarks inures solely to benefit of the \n' + + ' Partner Trademarks\' owner(s).\n' + + 'iv. The Company shall use the Partner Trademarks in a professional\n' + + ' manner consistent with industry standards and shall not use \n' + + ' them in any way that would reasonably be expected to diminish \n' + + ' their value or harm the reputation of the Partner Trademarks\' \n' + + ' owner(s). The Company\'s use of Partner Trademarks shall not \n' + + ' imply endorsement, sponsorship, or affiliation beyond the \n' + + ' existence of the Contribution in the Company\'s integration program.\n' + + 'v. The Company will not use the Partner Trademarks in connection \n' + + ' with search engine rankings, ad word purchases, or as part of a\n' + + ' trade name, business name, or Internet domain name.\n\n' + + '
\n\n' + + '**To unblock this PR, reply with exactly:**\n' + + '```\n' + + 'I agree to the Trademark License Addendum\n' + + 'CLA-SIGNATURE: ' + prAuthor + '\n' + + '```'; + + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: claText + }); + + await github.rest.issues.addLabels({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['cla-required', 'integrations-with-image-change'] + }); + } + } catch (error) { + console.error('Error in CLA comment step:', error); + throw error; + } \ No newline at end of file