]> git.alsa-project.org Git - alsa-ucm-conf.git/commitdiff
github: add GitHub label automation and SOB validation workflows
authorJaroslav Kysela <perex@perex.cz>
Mon, 30 Mar 2026 13:43:12 +0000 (15:43 +0200)
committerJaroslav Kysela <perex@perex.cz>
Mon, 30 Mar 2026 16:38:24 +0000 (18:38 +0200)
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 <noreply@anthropic.com>
Signed-off-by: Jaroslav Kysela <perex@perex.cz>
.github/label-descriptions.yml [new file with mode: 0644]
.github/workflows/label-automation.yml [new file with mode: 0644]
.github/workflows/pr-validation.yml [new file with mode: 0644]
.github/workflows/reusable-label-commenter.yml [new file with mode: 0644]
.github/workflows/reusable-sob-validator.yml [new file with mode: 0644]

diff --git a/.github/label-descriptions.yml b/.github/label-descriptions.yml
new file mode 100644 (file)
index 0000000..6cd51e3
--- /dev/null
@@ -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 <your.email@example.com>` 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 <author1@example.com>
+      # Signed-off-by: Author Two <author2@example.com>
+
+      # 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 (file)
index 0000000..d354176
--- /dev/null
@@ -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 (file)
index 0000000..d862b78
--- /dev/null
@@ -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 (file)
index 0000000..40f2ff1
--- /dev/null
@@ -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<<EOF" >> $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 = `<!-- label-commenter:${label} -->`;
+
+            // 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 (file)
index 0000000..ec65031
--- /dev/null
@@ -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<<EOF" >> $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 = '<!-- sob-validator -->';
+            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 <your.email@example.com>` 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 = '<!-- sob-validator -->';
+              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}`);
+            }