Skip to content

Commit

Permalink
Implement DCO check (#4)
Browse files Browse the repository at this point in the history
Signed-off-by: Sergio Castaño Arteaga <[email protected]>
  • Loading branch information
tegioz authored Aug 29, 2024
1 parent 6bb67e3 commit 6eb0788
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 35 deletions.
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ bytes = "1.7.1"
cached = { version = "0.53.1", features = ["async"] }
clap = { version = "4.5.16", features = ["derive"] }
chrono = "0.4.38"
email_address = "0.2.9"
figment = { version = "0.10.19", features = ["yaml", "env"] }
hmac = "0.12.1"
hex = "0.4.3"
http = "1.1.0"
lambda_http = "0.13.0"
octorust = "0.8.0-rc.1"
pem = "3.0.4"
regex = "1.10.6"
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.127"
sha2 = "0.10.8"
Expand Down
22 changes: 5 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,27 @@
# DCO2

**DCO2** is a GitHub App that enforces the [Developer Certificate of Origin](https://developercertificate.org/) (DCO) on Pull Requests.
**DCO2** is a GitHub App that enforces the [Developer Certificate of Origin](https://developercertificate.org/) (DCO) on Pull Requests. It aims to be a drop-in replacement for [dcoapp/app](https://github.com/dcoapp/app).

## Usage

To start using DCO2, you need to [configure the application](https://github.com/apps/dco-2) for your organization or repositories. To enforce the DCO check, you can enable [required status checks](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) in your branch protection settings.

## How it works

The Developer Certificate of Origin (DCO) is a lightweight way for contributors to certify that they wrote or otherwise have the right to submit the code they are contributing to the project.
The [Developer Certificate of Origin](https://developercertificate.org) (DCO) is a lightweight way for contributors to certify that they wrote or otherwise have the right to submit the code they are contributing to the project.

Here is the full [text of the DCO](https://developercertificate.org/), reformatted for readability:

> By making a contribution to this project, I certify that:
>
> a. The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or
>
> b. The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or
>
> c. The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it.
>
> d. I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved.
Contributors *sign-off* that they adhere to these requirements by adding a `Signed-off-by` line to commit messages.
Contributors *sign-off* that they adhere to [these requirements](https://developercertificate.org) by adding a `Signed-off-by` line to commit messages.

```text
This is my commit message
Signed-off-by: Joe Smith <[email protected]>
```

Git includes a `-s` command line option to append this automatically to your commit message (provided you have configured your `user.name` and `user.email` in your git configuration):
Git includes a `-s` command line option to append this line automatically to your commit message (provided you have configured your `user.name` and `user.email` in your git configuration):

```text
% git commit -s -m 'This is my commit message'
git commit -s -m 'This is my commit message'
```

Once installed, this application will create a check indicating whether or not commits in a Pull Request contain a valid `Signed-off-by` line.
Expand Down
2 changes: 2 additions & 0 deletions dco2/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ askama = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
email_address = { workspace = true }
http = { workspace = true }
octorust = { workspace = true }
pem = { workspace = true }
regex = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
Expand Down
146 changes: 134 additions & 12 deletions dco2/src/dco.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,64 @@ use crate::{
use anyhow::{Context, Result};
use askama::Template;
use chrono::Utc;
use email_address::EmailAddress;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use thiserror::Error;

/// Sign-off line regular expression.
static SIGN_OFF: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?mi)^Signed-off-by: (.*) <(.*)>\s*$").expect("expr in SIGN_OFF to be valid")
});

/// Check input.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CheckInput {
pub commits: Vec<Commit>,
}

/// Check output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Template)]
#[template(path = "output.md")]
pub struct CheckOutput {
pub check_passed: bool,
pub commits: Vec<CommitCheckOutput>,
}

/// Commit check output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommitCheckOutput {
pub commit: Commit,
pub errors: Vec<CommitError>,
}

/// Errors that may occur on a given commit during the check.
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CommitError {
#[error("invalid author email")]
InvalidAuthorEmail,
#[error("invalid committer email")]
InvalidCommitterEmail,
#[error("no sign-off matches the author or committer email")]
SignOffMismatch,
#[error("sign-off not found")]
SignOffNotFound,
}

/// Sign-off details.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SignOff {
name: String,
email: String,
kind: SignOffKind,
}

/// Sign-off kind.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
enum SignOffKind {
Explicit,
}

/// Process the GitHub webhook event provided.
pub async fn process_event(gh_client: DynGHClient, event: &Event) -> Result<()> {
Expand Down Expand Up @@ -49,20 +106,85 @@ pub async fn process_event(gh_client: DynGHClient, event: &Event) -> Result<()>
Ok(())
}

/// DCO check input.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CheckInput {
pub commits: Vec<Commit>,
/// Run DCO check.
pub fn check(input: &CheckInput) -> Result<CheckOutput> {
let mut output = CheckOutput {
check_passed: false,
commits: Vec::new(),
};

// Check each commit
for commit in &input.commits {
let mut commit_output = CommitCheckOutput {
commit: commit.clone(),
errors: Vec::new(),
};

// Validate author and committer emails
if let Err(err) = validate_emails(commit) {
commit_output.errors.push(err);
}

// Check if sign-off is present
let signoffs = get_signoffs(commit);
if signoffs.is_empty() {
commit_output.errors.push(CommitError::SignOffNotFound);
} else {
// Check if any of the sign-offs matches the author's or committer's email
if !signoffs_match(&signoffs, commit) {
commit_output.errors.push(CommitError::SignOffMismatch);
}
}

output.commits.push(commit_output);
}

// The check passes if none of the commits have errors
output.check_passed = output.commits.iter().any(|c| !c.errors.is_empty());

Ok(output)
}

/// DCO check output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Template)]
#[template(path = "output.md")]
pub struct CheckOutput {
pub check_passed: bool,
/// Validate author and committer emails.
fn validate_emails(commit: &Commit) -> Result<(), CommitError> {
// Author
if let Some(author) = &commit.author {
if !EmailAddress::is_valid(&author.email) {
return Err(CommitError::InvalidAuthorEmail);
}
}

// Committer
if let Some(committer) = &commit.committer {
if !EmailAddress::is_valid(&committer.email) {
return Err(CommitError::InvalidCommitterEmail);
}
}

Ok(())
}

/// Run DCO check.
pub fn check(_input: &CheckInput) -> Result<CheckOutput> {
Ok(CheckOutput { check_passed: true })
/// Get sign-offs found in the commit message.
fn get_signoffs(commit: &Commit) -> Vec<SignOff> {
let mut signoffs = Vec::new();

for (_, [name, email]) in SIGN_OFF.captures_iter(&commit.message).map(|c| c.extract()) {
signoffs.push(SignOff {
name: name.to_string(),
email: email.to_string(),
kind: SignOffKind::Explicit,
});
}

signoffs
}

/// Check if any of the sign-offs matches the author's or committer's email.
fn signoffs_match(signoffs: &[SignOff], commit: &Commit) -> bool {
let author_email = commit.author.as_ref().map(|a| &a.email);
let committer_email = commit.committer.as_ref().map(|c| &c.email);

signoffs
.iter()
.any(|s| Some(&s.email) == author_email || Some(&s.email) == committer_email)
}
16 changes: 10 additions & 6 deletions dco2/src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ impl GHClient for GHClientOctorust {
.body
.commits
.into_iter()
.map(|c| c.commit.into())
.map(Into::into)
.collect();

Ok(commits)
Expand Down Expand Up @@ -149,7 +149,9 @@ pub struct CheckRun {
pub struct Commit {
pub author: Option<CommitAuthor>,
pub committer: Option<CommitCommitter>,
pub html_url: String,
pub message: String,
pub sha: String,
}

/// Commit author information.
Expand All @@ -166,19 +168,21 @@ pub struct CommitCommitter {
pub email: String,
}

impl From<octorust::types::CommitData> for Commit {
impl From<octorust::types::CommitDataType> for Commit {
/// Convert octorust commit data to Commit.
fn from(oc: octorust::types::CommitData) -> Self {
fn from(c: octorust::types::CommitDataType) -> Self {
Self {
author: oc.author.map(|author| CommitAuthor {
author: c.commit.author.map(|author| CommitAuthor {
name: author.name,
email: author.email,
}),
committer: oc.committer.map(|committer| CommitCommitter {
committer: c.commit.committer.map(|committer| CommitCommitter {
name: committer.name,
email: committer.email,
}),
message: oc.message,
html_url: c.html_url,
message: c.commit.message,
sha: c.sha,
}
}
}
Expand Down

0 comments on commit 6eb0788

Please sign in to comment.