From 67998e199bc3dde9c0f431a5f26778cfe1edda21 Mon Sep 17 00:00:00 2001 From: Jaroslav Kysela Date: Mon, 30 Mar 2026 15:43:12 +0200 Subject: [PATCH] github: add GitHub label automation and SOB validation workflows This commit introduces a complete automation system for GitHub repositories that provides automatic label-based commenting and Signed-off-by validation. Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: Jaroslav Kysela --- .github/label-descriptions.yml | 62 ++++ .github/workflows/label-automation.yml | 20 ++ .github/workflows/pr-validation.yml | 24 ++ .../workflows/reusable-label-commenter.yml | 126 ++++++++ .github/workflows/reusable-sob-validator.yml | 295 ++++++++++++++++++ 5 files changed, 527 insertions(+) create mode 100644 .github/label-descriptions.yml create mode 100644 .github/workflows/label-automation.yml create mode 100644 .github/workflows/pr-validation.yml create mode 100644 .github/workflows/reusable-label-commenter.yml create mode 100644 .github/workflows/reusable-sob-validator.yml diff --git a/.github/label-descriptions.yml b/.github/label-descriptions.yml new file mode 100644 index 0000000..6cd51e3 --- /dev/null +++ b/.github/label-descriptions.yml @@ -0,0 +1,62 @@ +# Label descriptions configuration +# Each label can have a description that will be posted as a comment when the label is applied + +labels: + - name: kernel driver + description: | + ## ALSA Linux kernel driver work + + **Note:** This issue does not appear to be related to this repository, but rather seems to be an ALSA kernel driver issue. + + Please direct ALSA driver related discussions to the linux-sound@vger.kernel.org mailing list. + + **Bugtracker:** https://bugzilla.kernel.org (Audio component) + + For kernel patches, please see: https://www.kernel.org/doc/html/latest/process/submitting-patches.html + + - name: signed off by + description: | + ## Missing correct Signed-off-by line + + This pull request has commits that are missing proper `Signed-off-by` lines or use invalid email addresses. + + **Requirements:** + - Each commit must include at least one `Signed-off-by: Your Name ` line + - Commit author email must appear in at least one Signed-off-by line + - Commit committer email must appear in at least one Signed-off-by line (if not a GitHub bot) + - Multiple Signed-off-by lines are allowed for co-authored commits + - Anonymous GitHub emails (like `@users.noreply.github.com`) are not allowed + - The Signed-off-by line indicates that you agree to the Developer Certificate of Origin (DCO) + + **How to fix:** + ```bash + # Configure your git identity first (if needed): + git config user.name "Your Name" + git config user.email "your.email@example.com" + + # For the last commit: + git commit --amend --signoff + + # For multiple commits, use interactive rebase: + git rebase -i HEAD~N --signoff + + # For co-authored commits, add multiple Signed-off-by lines manually: + git commit --amend + # Then add in the commit message: + # Signed-off-by: Author One + # Signed-off-by: Author Two + + # Then force push: + git push --force-with-lease + ``` + + **Reference:** [Developer Certificate of Origin](https://developercertificate.org/) + +# Signed-off-by validation configuration +sob_validation: + # Email patterns to deny (supports wildcards) + denied_emails: + - '*@users.noreply.github.com' + # Add more patterns as needed: + # - '*@example-blocked-domain.com' + # - 'noreply@*' diff --git a/.github/workflows/label-automation.yml b/.github/workflows/label-automation.yml new file mode 100644 index 0000000..d354176 --- /dev/null +++ b/.github/workflows/label-automation.yml @@ -0,0 +1,20 @@ +# Example workflow for using the label commenter +# Place this file in your repository at: .github/workflows/label-automation.yml + +name: Label Automation + +on: + issues: + types: [labeled, unlabeled] + pull_request: + types: [labeled, unlabeled] + pull_request_target: + types: [labeled, unlabeled] + +jobs: + handle-label: + uses: ./.github/workflows/reusable-label-commenter.yml + with: + config-path: '.github/label-descriptions.yml' + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..d862b78 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,24 @@ +# Example workflow for automatic Signed-off-by validation +# Place this file in your repository at: .github/workflows/pr-validation.yml + +name: PR Validation + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + pr_number: + description: 'Pull Request number to validate' + required: true + type: number + +jobs: + validate-commits: + uses: ./.github/workflows/reusable-sob-validator.yml + with: + config-path: '.github/label-descriptions.yml' + sob-label: 'signed off by' + pr-number: ${{ github.event_name == 'workflow_dispatch' && format('{0}', inputs.pr_number) || '' }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reusable-label-commenter.yml b/.github/workflows/reusable-label-commenter.yml new file mode 100644 index 0000000..40f2ff1 --- /dev/null +++ b/.github/workflows/reusable-label-commenter.yml @@ -0,0 +1,126 @@ +name: Reusable Label Commenter + +on: + workflow_call: + inputs: + config-path: + description: 'Path to label descriptions config file' + required: false + type: string + default: '.github/label-descriptions.yml' + secrets: + github-token: + description: 'GitHub token for API access' + required: false + +jobs: + add-label-comment: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Read and parse config + id: read-config + run: | + if [ ! -f "${{ inputs.config-path }}" ]; then + echo "exists=false" >> $GITHUB_OUTPUT + echo "Warning: Config file ${{ inputs.config-path }} not found" + exit 0 + fi + + echo "exists=true" >> $GITHUB_OUTPUT + + # Convert YAML to JSON using Python + python3 << 'PYTHON_EOF' > /tmp/config.json + import yaml + import json + with open('${{ inputs.config-path }}', 'r') as f: + config = yaml.safe_load(f) + print(json.dumps(config)) + PYTHON_EOF + + # Store JSON as output (escaped for multiline) + echo "config<> $GITHUB_OUTPUT + cat /tmp/config.json >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Handle label action + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.github-token || secrets.GITHUB_TOKEN }} + script: | + // Get the label and action + const label = context.payload.label.name; + const action = context.payload.action; + + // Check if issue or PR + const issueNumber = context.payload.issue?.number || context.payload.pull_request?.number; + if (!issueNumber) { + console.log('No issue or PR number found'); + return; + } + + // Create unique identifier for this label comment + const commentId = ``; + + // Get existing comments + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + + // Find existing comment with this label ID + const existingComment = comments.find(comment => + comment.body?.includes(commentId) + ); + + if (action === 'unlabeled') { + // Label was removed - delete the comment if it exists + if (existingComment) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + }); + console.log(`Deleted comment for removed label: ${label}`); + } else { + console.log(`No comment found for removed label: ${label}`); + } + } else if (action === 'labeled') { + // Label was added - add or update comment + const config = JSON.parse(process.env.CONFIG_JSON || '{}'); + + // Find description for this label + const labelConfig = config.labels?.find(l => l.name === label); + + if (!labelConfig || !labelConfig.description) { + console.log(`No description configured for label: ${label}`); + return; + } + + const commentBody = `${commentId}\n${labelConfig.description}`; + + if (existingComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: commentBody, + }); + console.log(`Updated comment for label: ${label}`); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: commentBody, + }); + console.log(`Created comment for label: ${label}`); + } + } + env: + CONFIG_JSON: ${{ steps.read-config.outputs.config }} diff --git a/.github/workflows/reusable-sob-validator.yml b/.github/workflows/reusable-sob-validator.yml new file mode 100644 index 0000000..ec65031 --- /dev/null +++ b/.github/workflows/reusable-sob-validator.yml @@ -0,0 +1,295 @@ +name: Reusable Signed-off-by Validator + +on: + workflow_call: + inputs: + config-path: + description: 'Path to label descriptions config file' + required: false + type: string + default: '.github/label-descriptions.yml' + sob-label: + description: 'Label to add when SOB is missing or invalid' + required: false + type: string + default: 'signed off by' + pr-number: + description: 'PR number to validate (optional, auto-detects from event if not provided)' + required: false + type: string + default: '' + secrets: + github-token: + description: 'GitHub token for API access' + required: false + +jobs: + validate-signedoff: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine PR number + id: pr-number + uses: actions/github-script@v7 + with: + script: | + // Get PR number from input (if manually triggered) or from event + const inputPrNumber = '${{ inputs.pr-number }}'; + const prNumber = (inputPrNumber && inputPrNumber.trim() !== '') + ? inputPrNumber + : context.payload.pull_request?.number; + + if (!prNumber) { + core.setFailed('No PR number available from input or event'); + return; + } + core.setOutput('number', prNumber); + console.log(`Using PR number: ${prNumber}`); + + - name: Read and parse config + id: read-config + run: | + if [ ! -f "${{ inputs.config-path }}" ]; then + echo "exists=false" >> $GITHUB_OUTPUT + echo "{}" > /tmp/config.json + else + echo "exists=true" >> $GITHUB_OUTPUT + # Convert YAML to JSON using Python + python3 << 'PYTHON_EOF' > /tmp/config.json + import yaml + import json + with open('${{ inputs.config-path }}', 'r') as f: + config = yaml.safe_load(f) + print(json.dumps(config)) + PYTHON_EOF + fi + + # Store JSON as output (escaped for multiline) + echo "config<> $GITHUB_OUTPUT + cat /tmp/config.json >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Validate Signed-off-by + id: validate + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.github-token || secrets.GITHUB_TOKEN }} + script: | + // Parse config from JSON + const config = JSON.parse(process.env.CONFIG_JSON); + let deniedEmails = config.sob_validation?.denied_emails || []; + + // Add default denied patterns + if (!deniedEmails.includes('*@users.noreply.github.com')) { + deniedEmails.push('*@users.noreply.github.com'); + } + + const prNumber = ${{ steps.pr-number.outputs.number }}; + + // Get all commits in the PR + const { data: commits } = await github.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const issues = []; + + for (const commit of commits) { + const message = commit.commit.message; + const sha = commit.sha.substring(0, 7); + const authorEmail = commit.commit.author.email; + const committerEmail = commit.commit.committer.email; + + // Find all Signed-off-by lines + const sobPattern = /^Signed-off-by:\s+(.+)\s+<(.+)>$/gm; + const sobMatches = [...message.matchAll(sobPattern)]; + + if (sobMatches.length === 0) { + issues.push(`- Commit ${sha}: Missing Signed-off-by line`); + continue; + } + + // Extract all SOB emails + const sobEmails = sobMatches.map(match => match[2]); + + // Check if author email is in at least one Signed-off-by line + if (!sobEmails.includes(authorEmail)) { + issues.push(`- Commit ${sha}: Commit author email (${authorEmail}) not found in any Signed-off-by line`); + continue; + } + + // Check if committer email is in at least one Signed-off-by line + // (Skip check for GitHub web-flow and noreply bots which are common for web commits) + const isGitHubBot = committerEmail.includes('noreply.github.com') || + committerEmail === 'noreply@github.com'; + if (!isGitHubBot && !sobEmails.includes(committerEmail)) { + issues.push(`- Commit ${sha}: Commit committer email (${committerEmail}) not found in any Signed-off-by line`); + continue; + } + + // Check all SOB emails against denied patterns + let deniedEmailFound = false; + for (const sobEmail of sobEmails) { + for (const pattern of deniedEmails) { + const regex = new RegExp('^' + pattern.replace('*', '.*') + '$'); + if (regex.test(sobEmail)) { + issues.push(`- Commit ${sha}: Invalid email in Signed-off-by: ${sobEmail}`); + deniedEmailFound = true; + break; + } + } + if (deniedEmailFound) break; + } + if (deniedEmailFound) continue; + + // Check author email against denied patterns + let authorDenied = false; + for (const pattern of deniedEmails) { + const regex = new RegExp('^' + pattern.replace('*', '.*') + '$'); + if (regex.test(authorEmail)) { + issues.push(`- Commit ${sha}: Invalid commit author email: ${authorEmail}`); + authorDenied = true; + break; + } + } + if (authorDenied) continue; + + // Check committer email against denied patterns (skip GitHub bots) + if (!isGitHubBot) { + for (const pattern of deniedEmails) { + const regex = new RegExp('^' + pattern.replace('*', '.*') + '$'); + if (regex.test(committerEmail)) { + issues.push(`- Commit ${sha}: Invalid commit committer email: ${committerEmail}`); + break; + } + } + } + } + + // Store results + core.setOutput('has_issues', issues.length > 0); + core.setOutput('issues', issues.join('\n')); + + return issues.length > 0; + env: + CONFIG_JSON: ${{ steps.read-config.outputs.config }} + + - name: Add label if issues found + if: steps.validate.outputs.has_issues == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.github-token || secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.pr-number.outputs.number }}, + labels: ['${{ inputs.sob-label }}'], + }); + + - name: Add comment with issues + if: steps.validate.outputs.has_issues == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.github-token || secrets.GITHUB_TOKEN }} + script: | + const commentId = ''; + const issues = process.env.SOB_ISSUES; + + // Try to get custom message from config + let customMessage = ''; + const config = JSON.parse(process.env.CONFIG_JSON); + const labelConfig = config.labels?.find(l => l.name === '${{ inputs.sob-label }}'); + if (labelConfig?.description) { + customMessage = '\n\n' + labelConfig.description; + } + + const commentBody = commentId + '\n' + + '## ⚠️ Signed-off-by Validation Issues\n\n' + + 'The following commits have issues with their Signed-off-by lines:\n\n' + + issues + '\n\n' + + 'Please add a proper `Signed-off-by: Your Name ` line to each commit message.' + + customMessage; + + // Check for existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.pr-number.outputs.number }}, + }); + + const existingComment = comments.find(c => c.body?.includes(commentId)); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.pr-number.outputs.number }}, + body: commentBody, + }); + } + env: + SOB_ISSUES: ${{ steps.validate.outputs.issues }} + CONFIG_JSON: ${{ steps.read-config.outputs.config }} + + - name: Remove label and comment if no issues + if: steps.validate.outputs.has_issues == 'false' + uses: actions/github-script@v7 + continue-on-error: true + with: + github-token: ${{ secrets.github-token || secrets.GITHUB_TOKEN }} + script: | + const prNumber = parseInt('${{ steps.pr-number.outputs.number }}', 10); + const labelName = '${{ inputs.sob-label }}'; + + // Remove label + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: labelName, + }); + console.log(`Removed label: ${labelName}`); + } catch (error) { + if (error.status === 404) { + console.log('Label not present or already removed'); + } else { + console.log(`Error removing label: ${error.message}`); + } + } + + // Remove validation comment if it exists + try { + const commentId = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + + const existingComment = comments.find(c => c.body?.includes(commentId)); + + if (existingComment) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + }); + console.log('Removed validation comment'); + } + } catch (error) { + console.log(`Error removing comment: ${error.message}`); + } -- 2.52.0