diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 0000000..e3677f0 --- /dev/null +++ b/.github/workflows/dco.yml @@ -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 " 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 ' + 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}" +```