diff --git a/.github/dco-bot-allowlist.json b/.github/dco-bot-allowlist.json new file mode 100644 index 00000000..f64d0329 --- /dev/null +++ b/.github/dco-bot-allowlist.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "bots": [] +} diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index 0d8ebc82..c047bcd8 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -20,42 +20,17 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Verify DCO trailers + - name: Verify DCO policy fixtures + shell: bash + run: bash tools/test-dco-policy.sh + + - name: Verify pull-request commit sign-offs shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - - missing=0 - checked=0 - - while IFS= read -r commit; do - author_name="$(git show -s --format='%an' "$commit")" - author_email="$(git show -s --format='%ae' "$commit")" - - if [[ "$author_name" == *"[bot]"* ]] || - [[ "$author_email" == *"[bot]@users.noreply.github.com" ]]; then - echo "Bot exception: $commit $author_name <$author_email>" - continue - fi - - checked=$((checked + 1)) - if ! git show -s --format='%B' "$commit" | - grep -Eq '^Signed-off-by: .+ <[^<>[:space:]]+@[^<>[:space:]]+>$'; then - echo "::error::Commit $commit by $author_name <$author_email> lacks a valid Signed-off-by trailer." - missing=1 - fi - done < <(git rev-list "${BASE_SHA}..${HEAD_SHA}") - - if [[ "$checked" -eq 0 ]]; then - echo "No human-authored commits require DCO verification." - fi - - if [[ "$missing" -ne 0 ]]; then - echo "Add a DCO trailer with: git commit --amend --signoff" - exit 1 - fi - - echo "DCO verified for $checked human-authored commit(s)." + PR_ACTOR_LOGIN: ${{ github.event.pull_request.user.login }} + PR_ACTOR_ID: ${{ github.event.pull_request.user.id }} + PR_ACTOR_TYPE: ${{ github.event.pull_request.user.type }} + DCO_BOT_ALLOWLIST_PATH: ${{ github.workspace }}/.github/dco-bot-allowlist.json + run: bash tools/verify-dco.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84a9eed5..f8f741b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,11 @@ Create signed-off commits with: git commit --signoff ``` -The sign-off certifies that the contributor created the work or is authorized to submit it, and that it may be distributed under the repository's MIT License subject to clearly identified compatible third-party terms. The DCO workflow must pass before merge. +The sign-off certifies that the contributor created the work or is authorized to submit it, and that it may be distributed under the repository's MIT License subject to clearly identified compatible third-party terms. The trailer must match the commit author or committer identity. The DCO workflow must pass before merge. + +Bot exceptions are disabled by default. An author name or email containing `[bot]` is not trusted by itself. A bot may omit a personal sign-off only when the pull-request actor and commit emails match one exact record in `.github/dco-bot-allowlist.json`. + +The protected-branch requirements are documented in [Repository protection](docs/legal/REPOSITORY-PROTECTION.md). ## Product identity diff --git a/DCO.md b/DCO.md index 6bcdfe17..aff1f4c9 100644 --- a/DCO.md +++ b/DCO.md @@ -18,7 +18,9 @@ Every human-authored commit in a pull request must contain: Signed-off-by: Full Name ``` -The name and email must identify the person making the certification. A GitHub no-reply address may be used. Add the trailer automatically with: +The name and email must identify the commit author or committer making the certification. A GitHub no-reply address may be used. A trailer for an unrelated identity does not satisfy the check. + +Add the trailer automatically with: ```bash git commit --signoff @@ -59,10 +61,18 @@ AI tools do not provide provenance or permission. A contributor using AI assista - documenting material AI assistance in the pull request; - ensuring that the contributor can make the DCO certification for the final contribution. -## Exceptions +## Bot exceptions + +Bot exceptions are disabled by default. Text such as `[bot]` in commit author metadata does not create an exception. + +An unsigned automated commit may be exempt only when the pull-request actor and commit metadata match one exact trusted record in `.github/dco-bot-allowlist.json`. A record contains the GitHub login, numeric account ID, account type and permitted author/committer emails. Adding a bot requires a focused provenance review and update to `docs/legal/CONTRIBUTION-EXCEPTIONS.md`. -A DCO exception is not granted merely because adding a sign-off is inconvenient. Bot-generated dependency or maintenance commits may use the documented bot exception when the bot identity and generated source are clear. Any other exception requires a maintainer decision recorded in `docs/legal/CONTRIBUTION-EXCEPTIONS.md`. Supporting permissions that contain confidential information are retained outside the public repository; the public record identifies the scope, reviewer, date and decision without publishing privileged or personal material. +Any other exception requires a maintainer decision recorded in `docs/legal/CONTRIBUTION-EXCEPTIONS.md`. Supporting permissions that contain confidential information are retained outside the public repository; the public record identifies the scope, reviewer, date and decision without publishing privileged or personal material. ## Enforcement -`.github/workflows/dco.yml` checks every non-bot commit in a pull request for a valid `Signed-off-by` trailer. The DCO status should be configured as a required branch-protection check for protected branches. A pull request must not be merged while the DCO check is failing or a required provenance disclosure is unresolved. +`.github/workflows/dco.yml` runs repository-owned policy fixtures and checks every pull-request commit using `tools/verify-dco.sh`. The check requires an identity-matching sign-off or an exact trusted-bot allowlist match. + +The `main` branch must require the `Verify commit sign-offs` status and the build/test status before merge, require pull requests, and block direct/force pushes and deletion as documented in `docs/legal/REPOSITORY-PROTECTION.md`. + +A pull request must not be merged while the DCO check is failing or a required provenance disclosure is unresolved. diff --git a/docs/legal/CONTRIBUTION-EXCEPTIONS.md b/docs/legal/CONTRIBUTION-EXCEPTIONS.md index 648caee5..8cd98ca2 100644 --- a/docs/legal/CONTRIBUTION-EXCEPTIONS.md +++ b/docs/legal/CONTRIBUTION-EXCEPTIONS.md @@ -9,24 +9,32 @@ Confidential employer permissions, contracts, legal advice and personal informat - date; - pull request or commit; - contribution scope; -- contributor or bot identity; +- contributor or automation identity; - reason the ordinary DCO path was unavailable; - license and provenance reviewed; - supporting evidence location or custodian, without publishing confidential contents; - maintainer reviewer; - decision and conditions. -## Standing bot exception +## Bot exceptions -Automated commits from clearly identified maintenance bots may omit a personal DCO trailer when: +There is no standing exception based on an author name or email ending with `[bot]`. -- the bot identity ends with `[bot]`; +An unsigned automated commit may be exempt only when: + +- the pull-request actor login, numeric GitHub ID and actor type match one exact record in `.github/dco-bot-allowlist.json`; +- the commit author and committer emails match the same record; - the generated source and changed dependency or file are clear from the pull request; - a human maintainer reviews the resulting diff, license and provenance before merge; -- the contribution does not introduce unknown or incompatible material. +- the contribution does not introduce unknown or incompatible material; +- the allowlist addition or exception decision is reviewed in a focused pull request. + +The allowlist is empty by default. A bot exception does not exempt the pull request from dependency, license, build or maintainer review. + +## Emergency repository-protection exceptions -The DCO workflow exempts bot-authored commits on this basis. A bot exception does not exempt the pull request from dependency, license or maintainer review. +A temporary bypass of protected-branch settings requires a record containing the reason, exact duration, affected commit or pull request, reviewer, supporting evidence and restoration confirmation. A standing administrator bypass is not permitted by policy. ## Recorded exceptions -None as of 2026-07-29. +None as of 2026-07-30. diff --git a/docs/legal/CONTRIBUTION-PROVENANCE.md b/docs/legal/CONTRIBUTION-PROVENANCE.md index c6d2d0ac..a5879e52 100644 --- a/docs/legal/CONTRIBUTION-PROVENANCE.md +++ b/docs/legal/CONTRIBUTION-PROVENANCE.md @@ -35,15 +35,32 @@ A review of the available commit history did not identify a separately attribute Existing third-party dependencies, Microsoft runtime content and brand/source assets are governed separately by the dependency, notice and asset records in the repository; they are not treated as contributor-owned merely because they appear in repository or release files. +## Automated enforcement + +The repository-owned verifier in `tools/verify-dco.sh` checks each pull-request commit. It requires a `Signed-off-by` trailer matching the commit author or committer identity. + +Unsigned bot commits are not recognized from author-name text. A bot exception requires an exact match against `.github/dco-bot-allowlist.json` using the pull-request actor login, numeric GitHub account ID, actor type and commit author/committer emails. The allowlist is empty unless a focused provenance review adds a trusted automation identity. + +`tools/test-dco-policy.sh` proves that: + +- an identity-matching signed human commit passes; +- an unsigned human commit fails; +- a trailer for an unrelated identity fails; +- spoofed `[bot]` metadata from a human pull-request actor fails; +- an exact allowlisted GitHub bot identity passes; +- the same metadata with a different numeric actor ID fails. + ## Maintainer process Before merging a contribution, maintainers confirm: 1. the DCO check passes for every non-exempt commit; -2. the pull request contains the required provenance disclosures; -3. third-party terms are known and compatible; -4. any employer/client authority is credible; -5. AI-assisted material has documented human review; -6. any exception has a public decision record and private evidence where necessary. - -Repository or branch settings should require the DCO workflow status before merge. Direct pushes to protected branches should be restricted to maintainers and used only under the same provenance obligations. +2. each accepted trailer matches the commit author or committer identity; +3. any bot exception matches an exact trusted allowlist record; +4. the pull request contains the required provenance disclosures; +5. third-party terms are known and compatible; +6. any employer/client authority is credible; +7. AI-assisted material has documented human review; +8. any exception has a public decision record and private evidence where necessary. + +The `main` branch must require pull requests, the `Verify commit sign-offs` status and the build/test status, and must block direct/force pushes and deletion. The exact repository settings and evidence requirements are documented in [`REPOSITORY-PROTECTION.md`](REPOSITORY-PROTECTION.md). diff --git a/docs/legal/REPOSITORY-PROTECTION.md b/docs/legal/REPOSITORY-PROTECTION.md new file mode 100644 index 00000000..acb7eed8 --- /dev/null +++ b/docs/legal/REPOSITORY-PROTECTION.md @@ -0,0 +1,67 @@ +# Repository protection and DCO enforcement + +## Required protected-branch state + +The `main` branch must be governed by an active GitHub repository ruleset or equivalent branch protection with all of these properties: + +- changes enter `main` through a pull request; +- direct pushes are blocked; +- force pushes and branch deletion are blocked; +- the `Verify commit sign-offs` status check is required before merge; +- the main build/test status check is required before merge; +- required checks must pass on the current pull-request head; +- no standing administrator or maintainer bypass is enabled; +- emergency exceptions are temporary, documented in `CONTRIBUTION-EXCEPTIONS.md` and removed immediately after use. + +The project has one maintainer, so a separate approving reviewer is not required by this policy. The pull-request requirement and required automated checks remain mandatory. + +## Repository-enforced checks + +`.github/workflows/dco.yml` runs the repository-owned DCO verifier. It: + +1. tests the DCO policy against signed, unsigned, mismatched and spoofed-bot fixtures; +2. checks every pull-request commit; +3. requires a `Signed-off-by` trailer matching the commit author or committer identity; +4. permits an unsigned bot commit only when the pull-request actor login, numeric GitHub ID, actor type and commit emails match one exact record in `.github/dco-bot-allowlist.json`; +5. treats `[bot]` text in an author name or email as ordinary, untrusted commit metadata. + +The bot allowlist is empty by default. Adding an entry requires a focused pull request that identifies the automation owner, generated source, expected account identity and provenance controls. + +## Merge process + +Before merging, a maintainer verifies: + +- `Verify commit sign-offs` is successful; +- the build/test workflow is successful; +- the pull-request provenance checklist is complete; +- third-party and AI-assisted material is disclosed; +- any exception is recorded with supporting evidence. + +A merge performed while a required check is failing is a policy violation even if GitHub settings temporarily allow it. + +## Protection evidence + +After configuring or changing repository protection, record non-secret evidence in Issue #87 or its successor: + +- date and maintainer; +- protected branch or ruleset name; +- enforcement status; +- required status-check names; +- pull-request requirement; +- bypass actors, expected to be none; +- force-push and deletion settings; +- screenshot or exported API response location; +- a blocked test attempt or other evidence that an unsigned commit cannot merge. + +Do not commit access tokens, private administration URLs or confidential account information. + +## Review triggers + +Review this policy when: + +- workflow or job names change; +- a new bot or GitHub App needs an exception; +- repository ownership or maintainer roles change; +- merge methods or protected branches change; +- GitHub changes ruleset or branch-protection behavior; +- an unsigned, direct-push or bypass incident occurs. diff --git a/tools/test-dco-policy.sh b/tools/test-dco-policy.sh new file mode 100644 index 00000000..ef226af2 --- /dev/null +++ b/tools/test-dco-policy.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY_SCRIPT="$SCRIPT_DIR/verify-dco.sh" +TEMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEMP_ROOT"' EXIT + +REPO="$TEMP_ROOT/repo" +ALLOWLIST="$TEMP_ROOT/allowlist.json" +mkdir -p "$REPO" + +cat > "$ALLOWLIST" <<'JSON' +{ + "schemaVersion": 1, + "bots": [ + { + "login": "trusted-maintenance-bot[bot]", + "id": 1001, + "type": "Bot", + "authorEmails": [ + "1001+trusted-maintenance-bot[bot]@users.noreply.github.com" + ], + "committerEmails": [ + "1001+trusted-maintenance-bot[bot]@users.noreply.github.com" + ] + } + ] +} +JSON + +cd "$REPO" +git init -q +git config user.name "DCO Test Maintainer" +git config user.email "maintainer@example.test" +git commit --allow-empty -q -m "base" +BASE="$(git rev-parse HEAD)" + +run_verify() { + local head="$1" + local login="$2" + local id="$3" + local type="$4" + + BASE_SHA="$BASE" \ + HEAD_SHA="$head" \ + PR_ACTOR_LOGIN="$login" \ + PR_ACTOR_ID="$id" \ + PR_ACTOR_TYPE="$type" \ + DCO_BOT_ALLOWLIST_PATH="$ALLOWLIST" \ + bash "$VERIFY_SCRIPT" +} + +reset_repo() { + git reset --hard -q "$BASE" +} + +# A normal human commit with an identity-matching sign-off must pass. +git commit --allow-empty -q --signoff -m "signed human commit" +run_verify "$(git rev-parse HEAD)" "human-contributor" "2001" "User" +reset_repo + +# A human commit without a sign-off must fail. +git commit --allow-empty -q -m "unsigned human commit" +if run_verify "$(git rev-parse HEAD)" "human-contributor" "2001" "User"; then + echo "Unsigned human commit unexpectedly passed DCO verification." + exit 1 +fi +reset_repo + +# A valid-looking trailer for a different identity must fail. +git commit --allow-empty -q -m $'mismatched sign-off\n\nSigned-off-by: Another Person ' +if run_verify "$(git rev-parse HEAD)" "human-contributor" "2001" "User"; then + echo "Mismatched Signed-off-by identity unexpectedly passed DCO verification." + exit 1 +fi +reset_repo + +# Spoofing [bot] in commit metadata must fail when the PR actor is a human. +GIT_AUTHOR_NAME="trusted-maintenance-bot[bot]" \ +GIT_AUTHOR_EMAIL="1001+trusted-maintenance-bot[bot]@users.noreply.github.com" \ +GIT_COMMITTER_NAME="trusted-maintenance-bot[bot]" \ +GIT_COMMITTER_EMAIL="1001+trusted-maintenance-bot[bot]@users.noreply.github.com" \ + git commit --allow-empty -q -m "spoofed bot metadata" +if run_verify "$(git rev-parse HEAD)" "human-contributor" "2001" "User"; then + echo "Spoofed bot metadata unexpectedly passed DCO verification." + exit 1 +fi +reset_repo + +# An allowlisted GitHub bot identity with exact actor ID/type and emails may pass. +GIT_AUTHOR_NAME="trusted-maintenance-bot[bot]" \ +GIT_AUTHOR_EMAIL="1001+trusted-maintenance-bot[bot]@users.noreply.github.com" \ +GIT_COMMITTER_NAME="trusted-maintenance-bot[bot]" \ +GIT_COMMITTER_EMAIL="1001+trusted-maintenance-bot[bot]@users.noreply.github.com" \ + git commit --allow-empty -q -m "trusted bot commit" +run_verify "$(git rev-parse HEAD)" "trusted-maintenance-bot[bot]" "1001" "Bot" + +# The same metadata with a different GitHub actor ID must fail. +if run_verify "$(git rev-parse HEAD)" "trusted-maintenance-bot[bot]" "9999" "Bot"; then + echo "Bot with an untrusted GitHub actor ID unexpectedly passed DCO verification." + exit 1 +fi + +echo "DCO policy tests passed." diff --git a/tools/verify-dco.sh b/tools/verify-dco.sh new file mode 100644 index 00000000..0ef7469c --- /dev/null +++ b/tools/verify-dco.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${BASE_SHA:?BASE_SHA is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" + +ROOT="$(git rev-parse --show-toplevel)" +ALLOWLIST_PATH="${DCO_BOT_ALLOWLIST_PATH:-$ROOT/.github/dco-bot-allowlist.json}" +PR_ACTOR_LOGIN="${PR_ACTOR_LOGIN:-}" +PR_ACTOR_ID="${PR_ACTOR_ID:-}" +PR_ACTOR_TYPE="${PR_ACTOR_TYPE:-}" + +command -v git >/dev/null 2>&1 || { + echo "::error::git is required for DCO verification." + exit 1 +} +command -v jq >/dev/null 2>&1 || { + echo "::error::jq is required for trusted bot identity verification." + exit 1 +} + +if [[ ! -f "$ALLOWLIST_PATH" ]]; then + echo "::error::Trusted bot allowlist does not exist: $ALLOWLIST_PATH" + exit 1 +fi + +if ! jq -e ' + .schemaVersion == 1 and + (.bots | type == "array") and + all(.bots[]; + (.login | type == "string" and length > 0) and + (.id | type == "number" and . > 0) and + (.type == "Bot" or .type == "App") and + (.authorEmails | type == "array" and length > 0) and + (.committerEmails | type == "array" and length > 0) and + all(.authorEmails[]; type == "string" and length > 0) and + all(.committerEmails[]; type == "string" and length > 0) + ) and + (([.bots[].login] | length) == ([.bots[].login] | unique | length)) and + (([.bots[].id] | length) == ([.bots[].id] | unique | length)) +' "$ALLOWLIST_PATH" >/dev/null; then + echo "::error::Trusted bot allowlist has an invalid or duplicate identity record." + exit 1 +fi + +if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "::error::BASE_SHA does not identify an available commit: $BASE_SHA" + exit 1 +fi +if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then + echo "::error::HEAD_SHA does not identify an available commit: $HEAD_SHA" + exit 1 +fi + +is_trusted_bot_commit() { + local commit="$1" + local author_email committer_email matches + + if [[ -z "$PR_ACTOR_LOGIN" || -z "$PR_ACTOR_ID" || -z "$PR_ACTOR_TYPE" ]]; then + return 1 + fi + + author_email="$(git show -s --format='%ae' "$commit")" + committer_email="$(git show -s --format='%ce' "$commit")" + + matches="$(jq -r \ + --arg login "$PR_ACTOR_LOGIN" \ + --arg id "$PR_ACTOR_ID" \ + --arg type "$PR_ACTOR_TYPE" \ + --arg authorEmail "$author_email" \ + --arg committerEmail "$committer_email" ' + [ + .bots[] | + select( + .login == $login and + (.id | tostring) == $id and + .type == $type and + (((.authorEmails // []) | index($authorEmail)) != null) and + (((.committerEmails // []) | index($committerEmail)) != null) + ) + ] | length + ' "$ALLOWLIST_PATH")" + + [[ "$matches" == "1" ]] +} + +has_identity_signoff() { + local commit="$1" + local author_name author_email committer_name committer_email + local expected_author expected_committer line key value + + author_name="$(git show -s --format='%an' "$commit")" + author_email="$(git show -s --format='%ae' "$commit")" + committer_name="$(git show -s --format='%cn' "$commit")" + committer_email="$(git show -s --format='%ce' "$commit")" + expected_author="$author_name <$author_email>" + expected_committer="$committer_name <$committer_email>" + + while IFS= read -r line; do + [[ -n "$line" ]] || continue + key="${line%%:*}" + value="${line#*:}" + value="${value# }" + if [[ "${key,,}" == "signed-off-by" ]] && + [[ "$value" == "$expected_author" || "$value" == "$expected_committer" ]]; then + return 0 + fi + done < <(git show -s --format='%B' "$commit" | git interpret-trailers --parse) + + return 1 +} + +mapfile -t commits < <(git rev-list --reverse "${BASE_SHA}..${HEAD_SHA}") +if [[ "${#commits[@]}" -eq 0 ]]; then + echo "::error::No pull-request commits were found between BASE_SHA and HEAD_SHA." + exit 1 +fi + +checked=0 +trusted_bot_exceptions=0 +missing=0 + +for commit in "${commits[@]}"; do + author_name="$(git show -s --format='%an' "$commit")" + author_email="$(git show -s --format='%ae' "$commit")" + + if has_identity_signoff "$commit"; then + checked=$((checked + 1)) + echo "DCO sign-off verified: $commit $author_name <$author_email>" + continue + fi + + if is_trusted_bot_commit "$commit"; then + trusted_bot_exceptions=$((trusted_bot_exceptions + 1)) + echo "Trusted bot exception verified: $commit $PR_ACTOR_LOGIN ($PR_ACTOR_ID, $PR_ACTOR_TYPE)" + continue + fi + + echo "::error::Commit $commit by $author_name <$author_email> lacks a Signed-off-by trailer matching its author or committer identity." + missing=1 +done + +if [[ "$missing" -ne 0 ]]; then + echo "Add or repair the trailer with: git commit --amend --signoff" + exit 1 +fi + +echo "DCO verified for $checked signed commit(s) and $trusted_bot_exceptions trusted bot exception(s)."