Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions .github/workflows/dco.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
```yaml
# DCO (Developer Certificate of Origin) enforcement
#
# This workflow:
# - Checks every commit in a pull request
# - Requires at least one valid "Signed-off-by: Name <email>" trailer
# - Reports the exact commits that fail
# - Uses read-only permissions
# - Does not check out or execute pull-request code
#
# For organization-wide enforcement, store this workflow in the
# organization's workflow-policy repository and select it under:
# Organization Settings → Rulesets → Require workflows to pass before merging

name: DCO Sign-off

on:
pull_request_target:

permissions:
pull-requests: read

concurrency:
group: dco-${{ github.repository }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
dco:
name: Verify DCO sign-offs
runs-on: ubuntu-latest

steps:
- name: Check every pull-request commit
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}

run: |
set -Eeuo pipefail

api_get() {
curl \
--silent \
--show-error \
--fail-with-body \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2026-03-10" \
"$1"
}

pr_url="https://api.github.com/repos/${REPOSITORY}/pulls/${PR_NUMBER}"
pr_json="$(api_get "${pr_url}")"
total_commits="$(jq -r '.commits' <<<"${pr_json}")"

if [[ ! "${total_commits}" =~ ^[0-9]+$ ]]; then
echo "::error title=DCO check failed::Could not determine the number of commits in this pull request."
exit 1
fi

# GitHub's pull-request commits endpoint returns at most 250
# commits. Fail closed instead of silently skipping commits.
if (( total_commits > 250 )); then
{
echo "## ❌ DCO check could not complete"
echo
echo "This pull request contains ${total_commits} commits."
echo
echo "The GitHub pull-request commits API returns at most 250 commits."
echo "Please split this pull request into smaller pull requests."
} >>"${GITHUB_STEP_SUMMARY}"

echo "::error title=Too many commits::This pull request has ${total_commits} commits. Split it into pull requests containing no more than 250 commits."
exit 1
fi

commits_file="$(mktemp)"
missing_file="$(mktemp)"
: >"${commits_file}"
: >"${missing_file}"

# Retrieve all available commits, 100 per page.
for page in 1 2 3; do
page_url="${pr_url}/commits?per_page=100&page=${page}"
page_json="$(api_get "${page_url}")"
page_count="$(jq 'length' <<<"${page_json}")"

jq -c '.[]' <<<"${page_json}" >>"${commits_file}"

if (( page_count < 100 )); then
break
fi
done

listed_commits="$(
wc -l <"${commits_file}" | tr -d '[:space:]'
)"

# Fail closed if the API result is incomplete for any reason.
if (( listed_commits != total_commits )); then
{
echo "## ❌ DCO check could not complete"
echo
echo "Expected ${total_commits} commits but received ${listed_commits}."
echo
echo "No commits were approved because the complete pull request could not be evaluated."
} >>"${GITHUB_STEP_SUMMARY}"

echo "::error title=Incomplete commit list::Expected ${total_commits} commits but received ${listed_commits}."
exit 1
fi

# git interpret-trailers limits the check to the trailer block at
# the end of the commit message. This prevents arbitrary text in
# the commit body from being mistaken for a DCO sign-off.
while IFS= read -r commit; do
sha="$(jq -r '.sha' <<<"${commit}")"
message="$(jq -r '.commit.message' <<<"${commit}")"

trailers="$(
printf '%s\n' "${message}" |
git interpret-trailers --parse
)"

if ! grep -Eiq \
'^Signed-off-by:[[:space:]]+[^<>[:space:]][^<>]*[[:space:]]+<[^<>[:space:]]+@[^<>[:space:]]+>[[:space:]]*$' \
<<<"${trailers}"; then
printf '%s\n' "${sha}" >>"${missing_file}"
fi
done <"${commits_file}"

missing_count="$(
wc -l <"${missing_file}" | tr -d '[:space:]'
)"

if (( missing_count > 0 )); then
{
echo "## ❌ DCO sign-off missing"
echo
echo "${missing_count} of ${total_commits} commits do not contain a valid:"
echo
echo ' Signed-off-by: Your Name <your@email.com>'
echo
echo "### Affected commits"
echo

while IFS= read -r sha; do
short_sha="${sha:0:12}"
printf -- \
'- [`%s`](https://github.com/%s/commit/%s)\n' \
"${short_sha}" \
"${REPOSITORY}" \
"${sha}"
done <"${missing_file}"

echo
echo "### Fix the most recent commit"
echo
echo "~~~bash"
echo "git commit --amend --signoff"
echo "git push --force-with-lease"
echo "~~~"
echo
echo "### Fix multiple commits"
echo
echo "Replace N with the number of commits in your pull request:"
echo
echo "~~~bash"
echo "git rebase --signoff HEAD~N"
echo "git push --force-with-lease"
echo "~~~"
echo
echo "The check will run again automatically after the corrected commits are pushed."
} >>"${GITHUB_STEP_SUMMARY}"

while IFS= read -r sha; do
short_sha="${sha:0:12}"
echo "::error title=Missing DCO sign-off::Commit ${short_sha} does not contain a valid Signed-off-by trailer."
done <"${missing_file}"

exit 1
fi

{
echo "## ✅ DCO sign-off verified"
echo
echo "All ${total_commits} commits contain a valid Signed-off-by trailer."
} >>"${GITHUB_STEP_SUMMARY}"
```