id: cla-signature-checker
namespace: company.team
description: |
Blocks pull requests from authors who have not signed the contributor
license agreement. A webhook fires the flow when a pull request opens, the
commit list is read from the GitHub API, and every distinct author login or
email is checked against a signed CLA roster plus DCO Signed-off-by
trailers. Anyone still missing gets a GitHub issue requesting the signature
and a Slack ping to the maintainers, while a clean pull request passes
quietly. Every run exposes the verdict, the authors checked, the unsigned
list and the pull request number as typed outputs.
triggers:
- id: pull_request_opened
type: io.kestra.plugin.core.trigger.Webhook
description: Receives the pull request opened event from GitHub or a relay and
starts the check for that pull request.
key: "{{ secret('CLA_WEBHOOK_KEY') }}"
- id: nightly_resweep
type: io.kestra.plugin.core.trigger.Schedule
description: Disabled fallback that re-checks a pending pull request each
morning until someone switches it on.
cron: "0 6 * * *"
disabled: true
inputs:
- id: repo
type: STRING
defaults: acme-inc/platform
description: Repository that owns the pull request in owner/name form.
- id: pr_number
type: INT
defaults: 4187
description: Number of the pull request whose commit authors are inspected.
- id: author_login
type: STRING
defaults: octo-contributor
description: Login that opened the pull request, where a suffix of [bot] selects
the automation waiver roster.
tasks:
# --- 1. read the commit list of the pull request ---
- id: fetch_commits
type: io.kestra.plugin.core.http.Request
description: Reads every commit of the pull request so each author identity can
be checked against the roster.
method: GET
uri: "https://api.github.com/repos/{{ inputs.repo }}/pulls/{{ inputs.pr_number
}}/commits?per_page=100"
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github+json"
X-GitHub-Api-Version: "2022-11-28"
User-Agent: "cla-signature-checker"
retry:
type: exponential
interval: PT2S
maxInterval: PT15S
maxAttempts: 3
# --- 2. roster fixture and verdict, inside one shared directory ---
- id: verify_authors
type: io.kestra.plugin.core.flow.WorkingDirectory
description: Keeps the roster file written by the shell step and the Python
check that reads it in the same directory.
tasks:
- id: write_cla_roster
type: io.kestra.plugin.scripts.shell.Commands
description: Writes cla-signers.txt with the roster for this run, because
automation accounts cannot sign a contributor license agreement, so a
bot author gets the waiver roster and a human author gets the signed
roster.
env:
IS_BOT: "{{ (inputs.author_login | toJson | jq('endswith(\"[bot]\")') | first) ?
'true' : 'false' }}"
commands:
- |
set -eu
if [ "${IS_BOT}" = "true" ]; then
cat > cla-signers.txt <<'EOF'
# Automation accounts waived by policy
dependabot[bot]
renovate[bot]
github-actions[bot]
EOF
else
cat > cla-signers.txt <<'EOF'
# Signed contributor license agreement roster
alice-w
carol-dev
maintainer-moe
EOF
fi
echo "roster written for {{ inputs.author_login }} is_bot=${IS_BOT}"
- id: check_cla
type: io.kestra.plugin.scripts.python.Script
description: Reduces the commit payload to distinct author identities and
reports who still owes a signature.
containerImage: python:3.11-slim
inputFiles:
commits.json: "{{ outputs.fetch_commits.body | toJson }}"
script: |
import json
import re
COMMITS_FILE = "commits.json"
SIGNERS_FILE = "cla-signers.txt"
def load_payload(path):
try:
with open(path, "r", encoding="utf-8") as handle:
raw = handle.read().strip()
except OSError:
return None
if not raw:
return None
try:
data = json.loads(raw)
except ValueError:
return None
while isinstance(data, str):
try:
data = json.loads(data)
except ValueError:
return None
return data
def load_signers(path):
signers = set()
try:
with open(path, "r", encoding="utf-8") as handle:
lines = handle.read().splitlines()
except OSError:
return signers
for line in lines:
name = line.strip().lower()
if name and not name.startswith("#"):
signers.add(name)
return signers
def identity(entry):
author = entry.get("author") or {}
login = str(author.get("login") or "").strip()
commit_author = ((entry.get("commit") or {}).get("author")) or {}
emails = []
for candidate in (author.get("email"), commit_author.get("email")):
email = str(candidate or "").strip().lower()
if email and email not in emails:
emails.append(email)
return login, emails
def signoff_identities(message):
found = []
for line in message.splitlines():
if line.strip().lower().startswith("signed-off-by:"):
value = line.split(":", 1)[1].strip().lower()
match = re.search(r"<([^>]+)>", value)
found.append(match.group(1).strip() if match else value)
return found
payload = load_payload(COMMITS_FILE)
entries = payload if isinstance(payload, list) else []
signers = load_signers(SIGNERS_FILE)
order = []
signed = {}
for entry in entries:
if not isinstance(entry, dict):
continue
login, emails = identity(entry)
key = login or (emails[0] if emails else "unknown")
if key not in signed:
order.append(key)
signed[key] = False
granted = bool(login) and login.lower() in signers
granted = granted or any(email in signers for email in emails)
if not granted:
message = str(((entry.get("commit") or {}).get("message")) or "")
trail = signoff_identities(message)
granted = any(email in trail for email in emails)
granted = granted or (bool(login) and login.lower() in trail)
if granted:
signed[key] = True
checked = len(order)
unsigned = [key for key in order if not signed[key]]
if checked == 0:
unsigned = ["unknown"]
verdict = "BLOCK" if unsigned else "PASS"
result = {"verdict": verdict, "unsigned": unsigned, "checked": checked}
print("::" + json.dumps({"outputs": result}, ensure_ascii=True) + "::")
# --- 3. the gate ---
- id: cla_gate
type: io.kestra.plugin.core.flow.If
description: Files the signature request and pings Slack only when somebody is
missing, otherwise lets the pull request pass.
condition: "{{ (outputs.check_cla.vars.unsigned | length) > 0 }}"
then:
- id: file_signature_request
type: io.kestra.plugin.github.issues.Create
description: Opens a tracked issue that lists the authors who still owe a signature.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.repo }}"
title: "CLA signature required for pull request {{ inputs.pr_number }}"
body: |
## Contributor license agreement
Pull request {{ inputs.pr_number }} in `{{ inputs.repo }}` was opened by
`{{ inputs.author_login }}` and at least one commit author has no
contributor license agreement on file.
- Unsigned authors: {{ outputs.check_cla.vars.unsigned | join(', ') }}
- Authors checked: {{ outputs.check_cla.vars.checked }}
- Verdict: {{ outputs.check_cla.vars.verdict }}
Sign the CLA and push an empty commit, or ask a maintainer to re-run
this check once the signature is recorded.
labels:
- cla
- signature-required
- id: ping_maintainers
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Pings the compliance channel so a maintainer reviews the pull
request before merge.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"channel": "#contributor-compliance", "text": "CLA check blocked pull request {{ inputs.pr_number }} in {{ inputs.repo }}. Unsigned authors {{ outputs.check_cla.vars.unsigned | toJson }} out of {{ outputs.check_cla.vars.checked }} checked."}
else:
- id: log_all_clear
type: io.kestra.plugin.core.log.Log
description: Records a clean pass, where every checked author of the pull
request is on file.
message: "All {{ outputs.check_cla.vars.checked }} authors of pull request {{
inputs.pr_number }} are signed, verdict {{
outputs.check_cla.vars.verdict }}."
errors:
- id: notify_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Escalates any failure of the check to the ops channel with the
execution url.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"channel": "#contributor-compliance", "text": "FAILED: {{ flow.id }} {{ execution.id }}"}
outputs:
- id: verdict
type: STRING
value: "{{ outputs.check_cla.vars.verdict }}"
- id: checked_authors
type: INT
value: "{{ outputs.check_cla.vars.checked }}"
- id: unsigned
type: ARRAY
value: "{{ outputs.check_cla.vars.unsigned | toJson }}"
- id: pr_number
type: INT
value: "{{ inputs.pr_number }}"