DCO evidence contract¶
Extra CODEOWNERS contains a pure Developer Certificate of Origin (DCO) evaluator. It is not the DCO check that runs on pull requests today. No service or workflow calls it, and it cannot publish a Check Run.
The checked-in .github/workflows/dco.yml workflow remains active. Because a
pull request can change that workflow, treat its result as review evidence—not
as an independently enforced merge control. Issue #40 tracks the
independent caller, publication guard, live tests, and rollout.
This page defines the evidence that caller must collect. Incomplete, contradictory, stale, or ambiguous evidence must leave the check blocking.
Collection sequence¶
The caller must use a verified GitHub event and current GitHub API responses. It must not check out or execute code from the pull request.
GitHub independent caller pure evaluator
| | |
|-- verified PR event ------>| |
|<-- fetch PR (before) ------| |
|<-- list PR commit nodes ---| |
|<-- fetch each commit ------| |
|<-- fetch PR (after) -------| |
| |-- event + snapshots + commits --->|
| |<-- repo/PR/base/head decision ----|
|<-- revalidate + publish ---| |
not implemented
In order, the caller must:
- Parse the event snapshot from the pull request and top-level repository in the verified webhook payload.
- Fetch the current pull request and parse the
beforesnapshot. - Page through the pull request's GraphQL commit connection. Require the exact repository, pull request, base, and head identity on every page.
- Fetch each listed
PullRequestCommitnode and project only the fields the evaluator consumes. - Fetch the pull request again and parse the
aftersnapshot. - Evaluate only when the event,
before, andaftersnapshots match exactly. - Before publishing success, fetch the pull request once more and require the same repository, pull-request number, open state, base SHA, head SHA, and commit count. This publication guard does not exist yet.
Snapshot equality includes repository IDs and names, pull-request number and state, base and head refs, base and head SHAs, author identity, and commit count. A force-push, retarget, repository change, or concurrent commit makes the evidence stale.
An eligible REST snapshot is open, so every GraphQL response for it must report
OPEN. GitHub's REST pull-request state folds merged pull requests into
closed; for an already-ineligible closed snapshot, the adapter accepts either
GraphQL CLOSED or MERGED. The evaluator cannot pass either one.
A network, permission, rate-limit, parsing, or validation error does not produce a decision. The future caller must keep the required check blocking and retry from a fresh snapshot.
Snapshot-bound commit listing¶
The client uses GitHub's GraphQL PullRequest.commits connection.
The query selects commit node IDs and OIDs; it does not ask for files, patches,
or change statistics. A large diff therefore cannot make an unrelated DCO read
exceed its response limit.
Every page repeats and validates:
- repository database ID and
nameWithOwner - pull-request number and state
- base and head ref names and OIDs
- base and head repository identities
- total commit count and pagination state.
Each node must have a unique GraphQL ID and lowercase 40-character commit OID. Pages contain at most 100 nodes. The complete list contains at most 250 nodes, matches the snapshot count exactly, and ends at the snapshot head. A missing, repeated, malformed, or extra node fails collection.
Keeping the commit connection and base/head identity in the same response lets the client detect returned identity drift. Evidence from an observably different target cannot match the snapshot, and the client checks the identity again with each commit node.
GitHub does not document field-level snapshot isolation within a response or connection consistency between pages. Exact OIDs, graph checks, and the before/after snapshots still fail closed when the returned identity changes, but the provider behavior remains a live-contract assumption. Retarget and force-push races must pass that contract before rollout.
Commit details and graph checks¶
Each detail query addresses the PullRequestCommit node ID returned by the
listing. The response must repeat the same pull-request identity, node ID, and
commit OID. This keeps fork commits anchored to the base repository's pull
request without requesting REST commit file data.
The query selects parent OIDs, raw Git author and committer identity, the commit
message, GitHub's author user, and compact signature facts. The parser reduces
the message to ordinary and Dependabot sign-off booleans immediately. It never
retains the full message, raw signature, or signed payload in CommitEvidence.
At most two detail requests run concurrently, and the complete pull request has
a 16 MiB decoded-message budget.
The evaluator then requires:
- one detail response for every compared SHA, in the same order
- every parent that is also in the comparison to appear before its child
- every compared commit to be reachable from the exact head through compared parents.
Merge commits are valid. Stacked pull requests are also valid: a parent outside the current comparison does not need to appear.
GitHub's public GraphQL reference does not promise an order for this connection. The evaluator adds a fail-closed parent-before-child requirement, so the live contract fixture must prove that behavior before rollout. Cover merge graphs and deliberately skewed commit timestamps. The fixture must also prove that the base repository can fetch exact commit details for private-fork heads. Failure of either contract blocks rollout.
Ordinary sign-offs¶
An ordinary commit passes when one complete message line matches its raw Git author:
Signed-off-by: AUTHOR NAME <AUTHOR EMAIL>
The evaluator applies Python str.casefold() to both strings and then compares
them for equality. It does not normalize Unicode. This is deterministic across
runner locales and does not send author-controlled text to a shell.
Leading or trailing whitespace, a carriage return, extra text, or a matching
substring on a longer line does not pass. Git identities cannot contain the
< or > delimiters. Control and formatting characters are rejected before
matching.
The regression corpus records the intended Unicode behavior:
| Raw author | Trailer identity | Result |
|---|---|---|
Test Contributor <contributor@example.com> |
TEST CONTRIBUTOR <CONTRIBUTOR@EXAMPLE.COM> |
Pass |
Zoë <zoë@example.com> |
ZOË <ZOË@EXAMPLE.COM> |
Pass |
ΟΣ <sigma@example.com> |
ος <SIGMA@EXAMPLE.COM> |
Pass; final sigma case-folds to sigma |
İpek <ipek@example.com> |
i̇PEK <IPEK@EXAMPLE.COM> |
Pass; the first i is followed by a combining dot |
Straße <straße@example.com> |
STRASSE <STRASSE@EXAMPLE.COM> |
Pass; sharp S case-folds to ss |
José <jose@example.com> |
José <JOSE@EXAMPLE.COM> |
Fail; decomposed and precomposed accents are not normalized |
The active workflow delegates case-insensitive matching to GNU grep and the
runner locale. Python case folding is a deliberate replacement, not an assumed
equivalent. Before cutover, run this corpus through both implementations in a
disposable repository. Any difference is a migration failure until it is
explained, reviewed, and resolved; unit tests alone do not establish live
parity.
Results contain only fixed outcomes and commit SHAs. They never copy raw commit messages or identities into check output.
Dependabot fallback¶
The ordinary rule is tried first. An official Dependabot commit may instead
use GitHub's canonical dependabot[bot] <support@github.com> trailer, but only
when every predicate below matches.
| Evidence | Required value |
|---|---|
| Pull-request author | Login dependabot[bot], user ID 49699333, type Bot |
| Repository | Base and head repository IDs equal the evaluated repository ID |
| Branch and history | Head ref starts with dependabot/; the pull request has one commit |
| Commit position | Commit SHA is the exact head; its only parent is the exact base |
| GitHub commit author | Login dependabot[bot], user ID 49699333 |
| Raw Git author | dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
| Raw Git committer | GitHub <noreply@github.com> |
| Signature | isValid; state VALID; nonempty verification time; wasSignedByGitHub; signer login web-flow, user ID 19864447 |
| Trailer | Exact, case-sensitive line Signed-off-by: dependabot[bot] <support@github.com> |
The fallback stays case-sensitive and does not use Unicode case folding. Dependabot can still pass the ordinary route when its trailer matches its raw Git author.
Input limits and parsing¶
Evidence models are immutable and reject unknown fields. Their parsers copy only fields used by the decision.
| Field | Limit |
|---|---|
| Compared commits, detail responses, and results | 250 |
| One GraphQL commit-list page | 256 KiB after HTTP decoding |
| One GraphQL commit-detail response | 8 MiB after HTTP decoding |
| Concurrent commit-detail responses | 2 |
| Pull-request commit node ID | 512 UTF-8 bytes; no whitespace, control, or formatting characters |
| Pagination cursor | 4,096 UTF-8 bytes; nonempty and never repeated while another page is required |
| JSON container nesting | 64 levels |
| Parents per commit | 64 |
| Commit message | 1,000,000 UTF-8 bytes |
| All decoded commit messages in one collection | 16 MiB |
| Git author or committer name and email | 1,024 UTF-8 bytes per field |
| Base or head ref | 1,024 UTF-8 bytes |
| GitHub actor login and type | 256 and 64 UTF-8 bytes, respectively |
| Repository full name | 512 ASCII bytes |
| Signature state and timestamp | 128 UTF-8 bytes per field |
The bounded JSON reader decodes bytes strictly as UTF-8 before parsing. It rejects a UTF-8 byte-order mark, UTF-16, UTF-32, duplicate object keys, excessive nesting, and integers beyond Python's parser limit.
A pull request at the 250-commit limit requires at most 253 GraphQL calls per collection attempt: three commit-list pages and 250 detail queries. Only two detail queries run at once. Any API, rate-limit, parsing, or validation failure discards the attempt; the future caller must restart from a fresh snapshot and budget for the same worst-case call count on retry. Token exchange and the surrounding REST snapshot and publication reads are additional calls.
GitHub can report GraphQL primary and secondary rate limits
with HTTP 200 and a nonempty errors array. The client maps those responses to
the same bounded rate-limit error used for REST. It honors Retry-After first,
then X-RateLimit-Reset, and defaults to 60 seconds without usable timing
metadata. The raised error uses a fixed message instead of copying provider
error text into logs.
Commit messages may contain newlines, but not NUL. The GraphQL query omits raw
signature and payload fields entirely.
Repository names keep their exact case and must match
[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+; . and .. are invalid components. Refs,
actor fields, and Git identities reject control and formatting characters.
Result contract¶
Every result carries the evaluated repository identity, pull-request number, base SHA, and head SHA. That binding is required even for a failure, so a future publisher cannot mistake a decision for another snapshot.
Each commit has one fixed outcome:
author-signoffofficial-dependabotmissing-signoff.
A passing result has no failure reason. A failure uses one fixed value and does not include untrusted evidence:
| Failure | Meaning |
|---|---|
pull-request-not-open |
The event snapshot was not open. |
pull-request-changed |
The event, before, and after snapshots differed. |
comparison-mismatch |
The comparison was bound to a different repository, PR, base, or head. |
commit-count-mismatch |
Comparison metadata, compared commits, details, or snapshot count differed. |
duplicate-commit |
The comparison repeated a SHA. |
head-mismatch |
The comparison did not end at the exact snapshot head. |
commit-order-mismatch |
Detail order, parent order, or reachability disagreed. |
missing-signoff |
At least one commit passed neither sign-off route. |
Rollout boundary¶
GitHubClient.get_pull_commit_evidence performs the bounded commit listing and
detail collection. No runtime path calls it for DCO today.
The independent integration still must:
- run code that the evaluated pull request cannot modify
- limit its installation token to Pull requests: read, Contents: read, and Checks: write
- perform the publication guard against the exact repository, PR, base, head, state, and count
- publish a distinct required context bound to the expected GitHub App source
- leave API, validation, race, and publication failures blocking
- pass same-repository, fork, merge, stacked, retarget, force-push, Unicode, and negative source-binding cases in a disposable repository.
Do not replace the current DCO check or change repository rules based on this dormant layer. Follow issue #40 for the remaining implementation and live evidence.