Repository: rust-lang/crates.io
Pull request #11113: Add `PUT /api/v1/trusted_publishing/github_configs` API endpoint
Author: login: Turbo87
Labels: name: C-enhancement ✨
description: Category: Adding new behavior or a change to the way an existing feature works
name: A-backend ⚙️
Created: 2025-05-05T17:14:41Z | Merged: 2025-05-08T03:13:22Z

This PR implements the first API endpoint for "Trusted Publishing" support in crates.io.

`PUT /api/v1/trusted_publishing/github_configs` can be used to create Trusted Publishing configurations specific to GitHub Actions. As discussed in https://github.com/rust-lang/crates.io/pull/11062#discussion_r2059728214, it seems best to make the configurations provider-specific, and only have the token exchange endpoint be generic.

The endpoint is currently limited to cookie authentication, so that we don't have to commit to this API yet, since our frontend will be the only user. Once we have validated that we are happy with the API we can open it up to API token authentication, but that will likely mean introducing another token scope, which seems out of scope for this PR.

This PR is probably best reviewed commit by commit.

Related:

- https://github.com/rust-lang/crates.io/issues/10247
- https://github.com/rust-lang/rfcs/pull/3691
- https://github.com/rust-lang/crates.io/pull/11062


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 128742a8cd6..d8ce163842c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1198,6 +1198,7 @@ dependencies = [
  "crates_io_tarball",
  "crates_io_team_repo",
  "crates_io_test_db",
+ "crates_io_trustpub",
  "crates_io_worker",
  "csv",
  "deadpool-diesel",
@@ -1493,6 +1494,16 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "crates_io_trustpub"
+version = "0.0.0"
+dependencies = [
+ "claims",
+ "insta",
+ "regex",
+ "thiserror 2.0.12",
+]
+
 [[package]]
 name = "crates_io_worker"
 version = "0.0.0"
diff --git a/Cargo.toml b/Cargo.toml
index f92991385ee..97444ec9574 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -74,6 +74,7 @@ crates_io_pagerduty = { path = "crates/crates_io_pagerduty" }
 crates_io_session = { path = "crates/crates_io_session" }
 crates_io_tarball = { path = "crates/crates_io_tarball" }
 crates_io_team_repo = { path = "crates/crates_io_team_repo" }
+crates_io_trustpub = { path = "crates/crates_io_trustpub" }
 crates_io_worker = { path = "crates/crates_io_worker" }
 csv = "=1.3.1"
 chrono = { version = "=0.4.41", default-features = false, features = ["serde"] }
diff --git a/crates/crates_io_database/src/models/mod.rs b/crates/crates_io_database/src/models/mod.rs
index 2b6f2698adc..383d3b97443 100644
--- a/crates/crates_io_database/src/models/mod.rs
+++ b/crates/crates_io_database/src/models/mod.rs
@@ -33,5 +33,6 @@ pub mod krate;
 mod owner;
 pub mod team;
 pub mod token;
+pub mod trustpub;
 pub mod user;
 pub mod version;
diff --git a/crates/crates_io_database/src/models/trustpub/github_config.rs b/crates/crates_io_database/src/models/trustpub/github_config.rs
new file mode 100644
index 00000000000..c6291daca20
--- /dev/null
+++ b/crates/crates_io_database/src/models/trustpub/github_config.rs
@@ -0,0 +1,37 @@
+use crate::schema::trustpub_configs_github;
+use chrono::{DateTime, Utc};
+use diesel::prelude::*;
+use diesel_async::{AsyncPgConnection, RunQueryDsl};
+
+#[derive(Debug, Identifiable, Queryable, Selectable)]
+#[diesel(table_name = trustpub_configs_github, check_for_backend(diesel::pg::Pg))]
+pub struct GitHubConfig {
+    pub id: i32,
+    pub created_at: DateTime<Utc>,
+    pub crate_id: i32,
+    pub repository_owner: String,
+    pub repository_owner_id: i32,
+    pub repository_name: String,
+    pub workflow_filename: String,
+    pub environment: Option<String>,
+}
+
+#[derive(Debug, Insertable)]
+#[diesel(table_name = trustpub_configs_github, check_for_backend(diesel::pg::Pg))]
+pub struct NewGitHubConfig<'a> {
+    pub crate_id: i32,
+    pub repository_owner: &'a str,
+    pub repository_owner_id: i32,
+    pub repository_name: &'a str,
+    pub workflow_filename: &'a str,
+    pub environment: Option<&'a str>,
+}
+
+impl NewGitHubConfig<'_> {
+    pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult<GitHubConfig> {
+        self.insert_into(trustpub_configs_github::table)
+            .returning(GitHubConfig::as_returning())
+            .get_result(conn)
+            .await
+    }
+}
diff --git a/crates/crates_io_database/src/models/trustpub/mod.rs b/crates/crates_io_database/src/models/trustpub/mod.rs
new file mode 100644
index 00000000000..634f500f46d
--- /dev/null
+++ b/crates/crates_io_database/src/models/trustpub/mod.rs
@@ -0,0 +1,3 @@
+mod github_config;
+
+pub use self::github_config::{GitHubConfig, NewGitHubConfig};
diff --git a/crates/crates_io_github/examples/test_github_client.rs b/crates/crates_io_github/examples/test_github_client.rs
index a00f1d1689b..f8c259d495d 100644
--- a/crates/crates_io_github/examples/test_github_client.rs
+++ b/crates/crates_io_github/examples/test_github_client.rs
@@ -12,6 +12,11 @@ enum Request {
         #[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)]
         access_token: SecretString,
     },
+    GetUser {
+        name: String,
+        #[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)]
+        access_token: SecretString,
+    },
     OrgByName {
         org_name: String,
         #[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)]
@@ -58,6 +63,11 @@ async fn main() -> Result<()> {
             let response = github_client.current_user(&access_token).await?;
             println!("{response:#?}");
         }
+        Request::GetUser { name, access_token } => {
+            let access_token = AccessToken::new(access_token.expose_secret().into());
+            let response = github_client.get_user(&name, &access_token).await?;
+            println!("{response:#?}");
+        }
         Request::OrgByName {
             org_name,
             access_token,
diff --git a/crates/crates_io_github/src/lib.rs b/crates/crates_io_github/src/lib.rs
index a5463f78f0a..511faad6cc8 100644
--- a/crates/crates_io_github/src/lib.rs
+++ b/crates/crates_io_github/src/lib.rs
@@ -20,6 +20,7 @@ type Result<T> = std::result::Result<T, GitHubError>;
 #[async_trait]
 pub trait GitHubClient: Send + Sync {
     async fn current_user(&self, auth: &AccessToken) -> Result<GitHubUser>;
+    async fn get_user(&self, name: &str, auth: &AccessToken) -> Result<GitHubUser>;
     async fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> Result<GitHubOrganization>;
     async fn team_by_name(
         &self,
@@ -102,6 +103,11 @@ impl GitHubClient for RealGitHubClient {
         self.request("/user", auth).await
     }
 
+    async fn get_user(&self, name: &str, auth: &AccessToken) -> Result<GitHubUser> {
+        let url = format!("/users/{name}");
+        self.request(&url, auth).await
+    }
+
     async fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> Result<GitHubOrganization> {
         let url = format!("/orgs/{org_name}");
         self.request(&url, auth).await
diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml
new file mode 100644
index 00000000000..dbf9c1a5e36
--- /dev/null
+++ b/crates/crates_io_trustpub/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "crates_io_trustpub"
+version = "0.0.0"
+license = "MIT OR Apache-2.0"
+edition = "2024"
+
+[lints]
+workspace = true
+
+[dependencies]
+regex = "=1.11.1"
+thiserror = "=2.0.12"
+
+[dev-dependencies]
+claims = "=0.8.0"
+insta = "=1.43.1"
diff --git a/crates/crates_io_trustpub/README.md b/crates/crates_io_trustpub/README.md
new file mode 100644
index 00000000000..26029b63740
--- /dev/null
+++ b/crates/crates_io_trustpub/README.md
@@ -0,0 +1,3 @@
+# crates_io_trustpub
+
+This crate contains code related to the "[Trusted Publishing](https://github.com/rust-lang/rfcs/pull/3691)" feature of crates.io.
diff --git a/crates/crates_io_trustpub/src/github/mod.rs b/crates/crates_io_trustpub/src/github/mod.rs
new file mode 100644
index 00000000000..8695201df0e
--- /dev/null
+++ b/crates/crates_io_trustpub/src/github/mod.rs
@@ -0,0 +1 @@
+pub mod validation;
diff --git a/crates/crates_io_trustpub/src/github/validation.rs b/crates/crates_io_trustpub/src/github/validation.rs
new file mode 100644
index 00000000000..fdf708fca0f
--- /dev/null
+++ b/crates/crates_io_trustpub/src/github/validation.rs
@@ -0,0 +1,154 @@
+use std::sync::LazyLock;
+
+const MAX_FIELD_LENGTH: usize = 255;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ValidationError {
+    #[error("GitHub repository owner name may not be empty")]
+    OwnerEmpty,
+    #[error("GitHub repository owner name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    OwnerTooLong,
+    #[error("Invalid GitHub repository owner name")]
+    OwnerInvalid,
+
+    #[error("GitHub repository name may not be empty")]
+    RepoEmpty,
+    #[error("GitHub repository name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    RepoTooLong,
+    #[error("Invalid GitHub repository name")]
+    RepoInvalid,
+
+    #[error("Workflow filename may not be empty")]
+    WorkflowFilenameEmpty,
+    #[error("Workflow filename is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    WorkflowFilenameTooLong,
+    #[error("Workflow filename must end with `.yml` or `.yaml`")]
+    WorkflowFilenameMissingSuffix,
+    #[error("Workflow filename must be a filename only, without directories")]
+    WorkflowFilenameContainsSlash,
+
+    #[error("Environment name may not be empty (use `null` to omit)")]
+    EnvironmentEmptyString,
+    #[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    EnvironmentTooLong,
+    #[error("Environment name may not start with whitespace")]
+    EnvironmentStartsWithWhitespace,
+    #[error("Environment name may not end with whitespace")]
+    EnvironmentEndsWithWhitespace,
+    #[error(r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#)]
+    EnvironmentInvalidChars,
+}
+
+pub fn validate_owner(owner: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_OWNER: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$").unwrap());
+
+    if owner.is_empty() {
+        Err(ValidationError::OwnerEmpty)
+    } else if owner.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::OwnerTooLong)
+    } else if !RE_VALID_GITHUB_OWNER.is_match(owner) {
+        Err(ValidationError::OwnerInvalid)
+    } else {
+        Ok(())
+    }
+}
+
+pub fn validate_repo(repo: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_REPO: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9-_.]+$").unwrap());
+
+    if repo.is_empty() {
+        Err(ValidationError::RepoEmpty)
+    } else if repo.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::RepoTooLong)
+    } else if !RE_VALID_GITHUB_REPO.is_match(repo) {
+        Err(ValidationError::RepoInvalid)
+    } else {
+        Ok(())
+    }
+}
+
+pub fn validate_workflow_filename(filename: &str) -> Result<(), ValidationError> {
+    if filename.is_empty() {
+        Err(ValidationError::WorkflowFilenameEmpty)
+    } else if filename.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::WorkflowFilenameTooLong)
+    } else if !filename.ends_with(".yml") && !filename.ends_with(".yaml") {
+        Err(ValidationError::WorkflowFilenameMissingSuffix)
+    } else if filename.contains('/') {
+        Err(ValidationError::WorkflowFilenameContainsSlash)
+    } else {
+        Ok(())
+    }
+}
+
+pub fn validate_environment(env: &str) -> Result<(), ValidationError> {
+    static RE_INVALID_ENVIRONMENT_CHARS: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r#"[\x00-\x1F\x7F'"`,;\\]"#).unwrap());
+
+    if env.is_empty() {
+        Err(ValidationError::EnvironmentEmptyString)
+    } else if env.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::EnvironmentTooLong)
+    } else if env.starts_with(" ") {
+        Err(ValidationError::EnvironmentStartsWithWhitespace)
+    } else if env.ends_with(" ") {
+        Err(ValidationError::EnvironmentEndsWithWhitespace)
+    } else if RE_INVALID_ENVIRONMENT_CHARS.is_match(env) {
+        Err(ValidationError::EnvironmentInvalidChars)
+    } else {
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use claims::assert_err;
+    use insta::assert_snapshot;
+
+    #[test]
+    fn test_validate_owner() {
+        assert_snapshot!(assert_err!(validate_owner("")), @"GitHub repository owner name may not be empty");
+        assert_snapshot!(assert_err!(validate_owner(&"x".repeat(256))), @"GitHub repository owner name is too long (maximum is 255 characters)");
+        assert_snapshot!(assert_err!(validate_owner("invalid_characters@")), @"Invalid GitHub repository owner name");
+    }
+
+    #[test]
+    fn test_validate_repo() {
+        assert_snapshot!(assert_err!(validate_repo("")), @"GitHub repository name may not be empty");
+        assert_snapshot!(assert_err!(validate_repo(&"x".repeat(256))), @"GitHub repository name is too long (maximum is 255 characters)");
+        assert_snapshot!(assert_err!(validate_repo("$invalid#characters")), @"Invalid GitHub repository name");
+    }
+
+    #[test]
+    fn test_validate_workflow_filename() {
+        assert_snapshot!(assert_err!(validate_workflow_filename("")), @"Workflow filename may not be empty");
+        assert_snapshot!(assert_err!(validate_workflow_filename(&"x".repeat(256))), @"Workflow filename is too long (maximum is 255 characters)");
+        assert_snapshot!(assert_err!(validate_workflow_filename("missing_suffix")), @"Workflow filename must end with `.yml` or `.yaml`");
+        assert_snapshot!(assert_err!(validate_workflow_filename("/slash")), @"Workflow filename must end with `.yml` or `.yaml`");
+        assert_snapshot!(assert_err!(validate_workflow_filename("/many/slashes")), @"Workflow filename must end with `.yml` or `.yaml`");
+        assert_snapshot!(assert_err!(validate_workflow_filename("/slash.yml")), @"Workflow filename must be a filename only, without directories");
+    }
+
+    #[test]
+    fn test_validate_environment() {
+        assert_snapshot!(assert_err!(validate_environment("")), @"Environment name may not be empty (use `null` to omit)");
+        assert_snapshot!(assert_err!(validate_environment(&"x".repeat(256))), @"Environment name is too long (maximum is 255 characters)");
+        assert_snapshot!(assert_err!(validate_environment(" foo")), @"Environment name may not start with whitespace");
+        assert_snapshot!(assert_err!(validate_environment("foo ")), @"Environment name may not end with whitespace");
+        assert_snapshot!(assert_err!(validate_environment("'")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("\"")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("`")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment(",")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment(";")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("\\")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("\x00")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("\x1f")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("\x7f")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("\t")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("\r")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+        assert_snapshot!(assert_err!(validate_environment("\n")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
+    }
+}
diff --git a/crates/crates_io_trustpub/src/lib.rs b/crates/crates_io_trustpub/src/lib.rs
new file mode 100644
index 00000000000..da38d1f6d42
--- /dev/null
+++ b/crates/crates_io_trustpub/src/lib.rs
@@ -0,0 +1,3 @@
+#![doc = include_str!("../README.md")]
+
+pub mod github;
diff --git a/src/controllers.rs b/src/controllers.rs
index 5563b2eaced..cd055a67c18 100644
--- a/src/controllers.rs
+++ b/src/controllers.rs
@@ -13,5 +13,6 @@ pub mod site_metadata;
 pub mod summary;
 pub mod team;
 pub mod token;
+pub mod trustpub;
 pub mod user;
 pub mod version;
diff --git a/src/controllers/trustpub/github_configs/create/mod.rs b/src/controllers/trustpub/github_configs/create/mod.rs
new file mode 100644
index 00000000000..8aff9f0f062
--- /dev/null
+++ b/src/controllers/trustpub/github_configs/create/mod.rs
@@ -0,0 +1,129 @@
+use crate::app::AppState;
+use crate::auth::AuthCheck;
+use crate::controllers::krate::load_crate;
+use crate::controllers::trustpub::github_configs::emails::ConfigCreatedEmail;
+use crate::controllers::trustpub::github_configs::json;
+use crate::util::errors::{AppResult, bad_request};
+use axum::Json;
+use crates_io_database::models::OwnerKind;
+use crates_io_database::models::trustpub::NewGitHubConfig;
+use crates_io_database::schema::{crate_owners, emails, users};
+use crates_io_github::GitHubError;
+use crates_io_trustpub::github::validation::{
+    validate_environment, validate_owner, validate_repo, validate_workflow_filename,
+};
+use diesel::prelude::*;
+use diesel_async::RunQueryDsl;
+use http::request::Parts;
+use oauth2::AccessToken;
+use secrecy::ExposeSecret;
+
+#[cfg(test)]
+mod tests;
+
+/// Create a new Trusted Publishing configuration for GitHub Actions.
+#[utoipa::path(
+    put,
+    path = "/api/v1/trusted_publishing/github_configs",
+    security(("cookie" = [])),
+    request_body = inline(json::CreateRequest),
+    tag = "trusted_publishing",
+    responses((status = 200, description = "Successful Response", body = inline(json::CreateResponse))),
+)]
+pub async fn create_trustpub_github_config(
+    state: AppState,
+    parts: Parts,
+    json: json::CreateRequest,
+) -> AppResult<Json<json::CreateResponse>> {
+    let json_config = json.github_config;
+
+    validate_owner(&json_config.repository_owner)?;
+    validate_repo(&json_config.repository_name)?;
+    validate_workflow_filename(&json_config.workflow_filename)?;
+    if let Some(env) = &json_config.environment {
+        validate_environment(env)?;
+    }
+
+    let mut conn = state.db_write().await?;
+
+    let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?;
+    let auth_user = auth.user();
+
+    let krate = load_crate(&mut conn, &json_config.krate).await?;
+
+    let user_owners = crate_owners::table
+        .filter(crate_owners::crate_id.eq(krate.id))
+        .filter(crate_owners::deleted.eq(false))
+        .filter(crate_owners::owner_kind.eq(OwnerKind::User))
+        .inner_join(users::table)
+        .inner_join(emails::table.on(users::id.eq(emails::user_id)))
+        .select((users::id, users::gh_login, emails::email, emails::verified))
+        .load::<(i32, String, String, bool)>(&mut conn)
+        .await?;
+
+    if !user_owners.iter().any(|owner| owner.0 == auth_user.id) {
+        return Err(bad_request("You are not an owner of this crate"));
+    }
+
+    // Lookup `repository_owner_id` via GitHub API
+
+    let owner = &json_config.repository_owner;
+    let gh_auth = &auth_user.gh_access_token;
+    let gh_auth = AccessToken::new(gh_auth.expose_secret().to_string());
+    let github_user = match state.github.get_user(owner, &gh_auth).await {
+        Ok(user) => user,
+        Err(GitHubError::NotFound(_)) => Err(bad_request("Unknown GitHub user or organization"))?,
+        Err(err) => Err(err)?,
+    };
+
+    // Save the new GitHub OIDC config to the database
+
+    let new_config = NewGitHubConfig {
+        crate_id: krate.id,
+        // Use the normalized owner name as provided by GitHub.
+        repository_owner: &github_user.login,
+        repository_owner_id: github_user.id,
+        repository_name: &json_config.repository_name,
+        workflow_filename: &json_config.workflow_filename,
+        environment: json_config.environment.as_deref(),
+    };
+
+    let saved_config = new_config.insert(&mut conn).await?;
+
+    // Send notification emails to crate owners
+
+    let recipients = user_owners
+        .into_iter()
+        .filter(|(_, _, _, verified)| *verified)
+        .map(|(_, login, email, _)| (login, email))
+        .collect::<Vec<_>>();
+
+    for (recipient, email_address) in &recipients {
+        let email = ConfigCreatedEmail {
+            recipient,
+            user: &auth_user.gh_login,
+            krate: &krate.name,
+            repository_owner: &saved_config.repository_owner,
+            repository_name: &saved_config.repository_name,
+            workflow_filename: &saved_config.workflow_filename,
+            environment: saved_config.environment.as_deref().unwrap_or("(not set)"),
+        };
+
+        if let Err(err) = state.emails.send(email_address, email).await {
+            warn!("Failed to send trusted publishing notification to {email_address}: {err}")
+        }
+    }
+
+    let github_config = json::GitHubConfig {
+        id: saved_config.id,
+        krate: krate.name,
+        repository_owner: saved_config.repository_owner,
+        repository_owner_id: saved_config.repository_owner_id,
+        repository_name: saved_config.repository_name,
+        workflow_filename: saved_config.workflow_filename,
+        environment: saved_config.environment,
+        created_at: saved_config.created_at,
+    };
+
+    Ok(Json(json::CreateResponse { github_config }))
+}
diff --git a/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path-2.snap b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path-2.snap
new file mode 100644
index 00000000000..ee0e0e731c9
--- /dev/null
+++ b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path-2.snap
@@ -0,0 +1,29 @@
+---
+source: src/controllers/trustpub/github_configs/create/tests.rs
+expression: app.emails_snapshot().await
+---
+To: foo@example.com
+From: crates.io <noreply@crates.io>
+Subject: crates.io: Trusted Publishing configration added to foo
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+
+Hello foo!
+
+crates.io user foo has added a new "Trusted Publishing" configuration for G=
+itHub Actions to a crate that you manage (foo). Trusted publishers act as t=
+rusted users and can publish new versions of the crate automatically.
+
+Trusted Publishing configuration:
+
+- Repository owner: rust-lang
+- Repository name: foo-rs
+- Workflow filename: publish.yml
+- Environment: (not set)
+
+If you did not make this change and you think it was made maliciously, you =
+can remove the configuration from the crate via the "Settings" tab on the c=
+rate's page.
+
+If you are unable to revert the change and need to do so, you can email hel=
+p@crates.io to communicate with the crates.io support team.
diff --git a/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path.snap b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path.snap
new file mode 100644
index 00000000000..9b76d1fe41b
--- /dev/null
+++ b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path.snap
@@ -0,0 +1,16 @@
+---
+source: src/controllers/trustpub/github_configs/create/tests.rs
+expression: response.json()
+---
+{
+  "github_config": {
+    "crate": "foo",
+    "created_at": "[datetime]",
+    "environment": null,
+    "id": 1,
+    "repository_name": "foo-rs",
+    "repository_owner": "rust-lang",
+    "repository_owner_id": 42,
+    "workflow_filename": "publish.yml"
+  }
+}
diff --git a/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path_with_environment.snap b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path_with_environment.snap
new file mode 100644
index 00000000000..e9f65c508ab
--- /dev/null
+++ b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path_with_environment.snap
@@ -0,0 +1,16 @@
+---
+source: src/controllers/trustpub/github_configs/create/tests.rs
+expression: response.json()
+---
+{
+  "github_config": {
+    "crate": "foo",
+    "created_at": "[datetime]",
+    "environment": "production",
+    "id": 1,
+    "repository_name": "foo-rs",
+    "repository_owner": "rust-lang",
+    "repository_owner_id": 42,
+    "workflow_filename": "publish.yml"
+  }
+}
diff --git a/src/controllers/trustpub/github_configs/create/tests.rs b/src/controllers/trustpub/github_configs/create/tests.rs
new file mode 100644
index 00000000000..9905d03375c
--- /dev/null
+++ b/src/controllers/trustpub/github_configs/create/tests.rs
@@ -0,0 +1,372 @@
+use crate::tests::builders::CrateBuilder;
+use crate::tests::util::{RequestHelper, Response, TestApp};
+use anyhow::anyhow;
+use bytes::Bytes;
+use crates_io_database::schema::trustpub_configs_github;
+use crates_io_github::{GitHubError, GitHubUser, MockGitHubClient};
+use diesel::prelude::*;
+use diesel_async::RunQueryDsl;
+use http::StatusCode;
+use insta::{assert_json_snapshot, assert_snapshot};
+use serde_json::json;
+
+const URL: &str = "/api/v1/trusted_publishing/github_configs";
+
+const CRATE_NAME: &str = "foo";
+
+fn simple_github_mock() -> MockGitHubClient {
+    let mut github_mock = MockGitHubClient::new();
+    github_mock.expect_get_user().returning(|login, _| {
+        Ok(GitHubUser {
+            avatar_url: None,
+            email: None,
+            id: 42,
+            login: login.into(),
+            name: None,
+        })
+    });
+    github_mock
+}
+
+async fn run_test(payload: impl Into<Bytes>) -> (TestApp, Response<()>) {
+    async fn inner(payload: Bytes) -> (TestApp, Response<()>) {
+        let (app, _client, cookie_client) = TestApp::full()
+            .with_github(simple_github_mock())
+            .with_user()
+            .await;
+
+        let mut conn = app.db_conn().await;
+
+        CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
+            .build(&mut conn)
+            .await
+            .unwrap();
+
+        (app, cookie_client.put::<()>(URL, payload).await)
+    }
+
+    inner(payload.into()).await
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_happy_path() -> anyhow::Result<()> {
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let (app, response) = run_test(body).await;
+    assert_eq!(response.status(), StatusCode::OK);
+    assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" });
+
+    assert_snapshot!(app.emails_snapshot().await);
+
+    let mut conn = app.db_conn().await;
+    let config_ids = trustpub_configs_github::table
+        .select(trustpub_configs_github::id)
+        .get_results::<i32>(&mut conn)
+        .await?;
+
+    assert_eq!(config_ids.len(), 1);
+    assert_eq!(config_ids[0], 1);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_happy_path_with_environment() -> anyhow::Result<()> {
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": "production",
+        }
+    }))?;
+
+    let (_app, response) = run_test(body).await;
+    assert_eq!(response.status(), StatusCode::OK);
+    assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" });
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_empty_body() -> anyhow::Result<()> {
+    let (_app, response) = run_test("").await;
+    assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Expected request with `Content-Type: application/json`"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_empty_json_object() -> anyhow::Result<()> {
+    let (_app, response) = run_test("{}").await;
+    assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to deserialize the JSON body into the target type: missing field `github_config` at line 1 column 2"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_invalid_owner() -> anyhow::Result<()> {
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "§$%&",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let (_app, response) = run_test(body).await;
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid GitHub repository owner name"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_invalid_repo() -> anyhow::Result<()> {
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "@foo",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let (_app, response) = run_test(body).await;
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid GitHub repository name"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_invalid_workflow() -> anyhow::Result<()> {
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "workflows/ci.json",
+            "environment": null,
+        }
+    }))?;
+
+    let (_app, response) = run_test(body).await;
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Workflow filename must end with `.yml` or `.yaml`"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_invalid_environment() -> anyhow::Result<()> {
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": "",
+        }
+    }))?;
+
+    let (_app, response) = run_test(body).await;
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Environment name may not be empty (use `null` to omit)"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_unauthenticated() -> anyhow::Result<()> {
+    let (app, client, cookie_client) = TestApp::full()
+        .with_github(simple_github_mock())
+        .with_user()
+        .await;
+
+    let mut conn = app.db_conn().await;
+
+    CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
+        .build(&mut conn)
+        .await?;
+
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let response = client.put::<()>(URL, body).await;
+    assert_eq!(response.status(), StatusCode::FORBIDDEN);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_token_auth() -> anyhow::Result<()> {
+    let (app, _client, cookie_client, token_client) = TestApp::full()
+        .with_github(simple_github_mock())
+        .with_token()
+        .await;
+
+    let mut conn = app.db_conn().await;
+
+    CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
+        .build(&mut conn)
+        .await?;
+
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let response = token_client.put::<()>(URL, body).await;
+    assert_eq!(response.status(), StatusCode::FORBIDDEN);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action can only be performed on the crates.io website"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_missing_crate() -> anyhow::Result<()> {
+    let (_app, _client, cookie_client) = TestApp::full()
+        .with_github(simple_github_mock())
+        .with_user()
+        .await;
+
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let response = cookie_client.put::<()>(URL, body).await;
+    assert_eq!(response.status(), StatusCode::NOT_FOUND);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"crate `foo` does not exist"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_non_owner() -> anyhow::Result<()> {
+    let (app, _client, cookie_client) = TestApp::full()
+        .with_github(simple_github_mock())
+        .with_user()
+        .await;
+
+    let mut conn = app.db_conn().await;
+
+    CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
+        .build(&mut conn)
+        .await?;
+
+    let other_client = app.db_new_user("other_user").await;
+
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let response = other_client.put::<()>(URL, body).await;
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_unknown_github_user() -> anyhow::Result<()> {
+    let mut github_mock = MockGitHubClient::new();
+    github_mock
+        .expect_get_user()
+        .returning(|login, _| Err(GitHubError::NotFound(anyhow!("User {} not found", login))));
+
+    let (app, _client, cookie_client) = TestApp::full().with_github(github_mock).with_user().await;
+
+    let mut conn = app.db_conn().await;
+
+    CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
+        .build(&mut conn)
+        .await?;
+
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let response = cookie_client.put::<()>(URL, body).await;
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Unknown GitHub user or organization"}]}"#);
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_github_error() -> anyhow::Result<()> {
+    let mut github_mock = MockGitHubClient::new();
+    github_mock
+        .expect_get_user()
+        .returning(|_, _| Err(GitHubError::Other(anyhow!("Internal Server Error"))));
+
+    let (app, _client, cookie_client) = TestApp::full().with_github(github_mock).with_user().await;
+
+    let mut conn = app.db_conn().await;
+
+    CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
+        .build(&mut conn)
+        .await?;
+
+    let body = serde_json::to_vec(&json!({
+        "github_config": {
+            "crate": CRATE_NAME,
+            "repository_owner": "rust-lang",
+            "repository_name": "foo-rs",
+            "workflow_filename": "publish.yml",
+            "environment": null,
+        }
+    }))?;
+
+    let response = cookie_client.put::<()>(URL, body).await;
+    assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+    assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Internal Server Error"}]}"#);
+
+    Ok(())
+}
diff --git a/src/controllers/trustpub/github_configs/emails.rs b/src/controllers/trustpub/github_configs/emails.rs
new file mode 100644
index 00000000000..026266110d4
--- /dev/null
+++ b/src/controllers/trustpub/github_configs/emails.rs
@@ -0,0 +1,50 @@
+use crate::email::Email;
+
+/// Email template for notifying crate owners about a new crate version
+/// being published.
+#[derive(Debug, Clone)]
+pub struct ConfigCreatedEmail<'a> {
+    pub recipient: &'a str,
+    pub user: &'a str,
+    pub krate: &'a str,
+    pub repository_owner: &'a str,
+    pub repository_name: &'a str,
+    pub workflow_filename: &'a str,
+    pub environment: &'a str,
+}
+
+impl Email for ConfigCreatedEmail<'_> {
+    fn subject(&self) -> String {
+        let Self { krate, .. } = self;
+        format!("crates.io: Trusted Publishing configration added to {krate}")
+    }
+
+    fn body(&self) -> String {
+        let Self {
+            recipient,
+            user,
+            krate,
+            repository_owner,
+            repository_name,
+            workflow_filename,
+            environment,
+        } = self;
+
+        format!(
+            "Hello {recipient}!
+
+crates.io user {user} has added a new \"Trusted Publishing\" configuration for GitHub Actions to a crate that you manage ({krate}). Trusted publishers act as trusted users and can publish new versions of the crate automatically.
+
+Trusted Publishing configuration:
+
+- Repository owner: {repository_owner}
+- Repository name: {repository_name}
+- Workflow filename: {workflow_filename}
+- Environment: {environment}
+
+If you did not make this change and you think it was made maliciously, you can remove the configuration from the crate via the \"Settings\" tab on the crate's page.
+
+If you are unable to revert the change and need to do so, you can email help@crates.io to communicate with the crates.io support team."
+        )
+    }
+}
diff --git a/src/controllers/trustpub/github_configs/json.rs b/src/controllers/trustpub/github_configs/json.rs
new file mode 100644
index 00000000000..4d44e303bc0
--- /dev/null
+++ b/src/controllers/trustpub/github_configs/json.rs
@@ -0,0 +1,49 @@
+use axum::Json;
+use axum::extract::FromRequest;
+use chrono::{DateTime, Utc};
+
+#[derive(Debug, Serialize, utoipa::ToSchema)]
+pub struct GitHubConfig {
+    #[schema(example = 42)]
+    pub id: i32,
+    #[schema(example = "regex")]
+    #[serde(rename = "crate")]
+    pub krate: String,
+    #[schema(example = "rust-lang")]
+    pub repository_owner: String,
+    #[schema(example = 5430905)]
+    pub repository_owner_id: i32,
+    #[schema(example = "regex")]
+    pub repository_name: String,
+    #[schema(example = "ci.yml")]
+    pub workflow_filename: String,
+    #[schema(example = json!(null))]
+    pub environment: Option<String>,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Deserialize, utoipa::ToSchema)]
+pub struct NewGitHubConfig {
+    #[schema(example = "regex")]
+    #[serde(rename = "crate")]
+    pub krate: String,
+    #[schema(example = "rust-lang")]
+    pub repository_owner: String,
+    #[schema(example = "regex")]
+    pub repository_name: String,
+    #[schema(example = "ci.yml")]
+    pub workflow_filename: String,
+    #[schema(example = json!(null))]
+    pub environment: Option<String>,
+}
+
+#[derive(Debug, Deserialize, FromRequest, utoipa::ToSchema)]
+#[from_request(via(Json))]
+pub struct CreateRequest {
+    pub github_config: NewGitHubConfig,
+}
+
+#[derive(Debug, Serialize, utoipa::ToSchema)]
+pub struct CreateResponse {
+    pub github_config: GitHubConfig,
+}
diff --git a/src/controllers/trustpub/github_configs/mod.rs b/src/controllers/trustpub/github_configs/mod.rs
new file mode 100644
index 00000000000..9ebca9977f5
--- /dev/null
+++ b/src/controllers/trustpub/github_configs/mod.rs
@@ -0,0 +1,3 @@
+pub mod create;
+pub mod emails;
+pub mod json;
diff --git a/src/controllers/trustpub/mod.rs b/src/controllers/trustpub/mod.rs
new file mode 100644
index 00000000000..6903a27236a
--- /dev/null
+++ b/src/controllers/trustpub/mod.rs
@@ -0,0 +1 @@
+pub mod github_configs;
diff --git a/src/router.rs b/src/router.rs
index 88ce4c62beb..b589892cf96 100644
--- a/src/router.rs
+++ b/src/router.rs
@@ -87,6 +87,10 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
         .routes(routes!(session::begin_session))
         .routes(routes!(session::authorize_session))
         .routes(routes!(session::end_session))
+        // OIDC / Trusted Publishing
+        .routes(routes!(
+            trustpub::github_configs::create::create_trustpub_github_config,
+        ))
         .split_for_parts();
 
     let mut router = router
diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap
index cb7a074036a..2f1b4601c27 100644
--- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap
+++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap
@@ -598,6 +598,57 @@ expression: response.json()
         ],
         "type": "string"
       },
+      "GitHubConfig": {
+        "properties": {
+          "crate": {
+            "example": "regex",
+            "type": "string"
+          },
+          "created_at": {
+            "format": "date-time",
+            "type": "string"
+          },
+          "environment": {
+            "example": null,
+            "type": [
+              "string",
+              "null"
+            ]
+          },
+          "id": {
+            "example": 42,
+            "format": "int32",
+            "type": "integer"
+          },
+          "repository_name": {
+            "example": "regex",
+            "type": "string"
+          },
+          "repository_owner": {
+            "example": "rust-lang",
+            "type": "string"
+          },
+          "repository_owner_id": {
+            "example": 5430905,
+            "format": "int32",
+            "type": "integer"
+          },
+          "workflow_filename": {
+            "example": "ci.yml",
+            "type": "string"
+          }
+        },
+        "required": [
+          "id",
+          "crate",
+          "repository_owner",
+          "repository_owner_id",
+          "repository_name",
+          "workflow_filename",
+          "created_at"
+        ],
+        "type": "object"
+      },
       "Keyword": {
         "properties": {
           "crates_cnt": {
@@ -685,6 +736,40 @@ expression: response.json()
         ],
         "type": "object"
       },
+      "NewGitHubConfig": {
+        "properties": {
+          "crate": {
+            "example": "regex",
+            "type": "string"
+          },
+          "environment": {
+            "example": null,
+            "type": [
+              "string",
+              "null"
+            ]
+          },
+          "repository_name": {
+            "example": "regex",
+            "type": "string"
+          },
+          "repository_owner": {
+            "example": "rust-lang",
+            "type": "string"
+          },
+          "workflow_filename": {
+            "example": "ci.yml",
+            "type": "string"
+          }
+        },
+        "required": [
+          "crate",
+          "repository_owner",
+          "repository_name",
+          "workflow_filename"
+        ],
+        "type": "object"
+      },
       "Owner": {
         "properties": {
           "avatar": {
@@ -4047,6 +4132,58 @@ expression: response.json()
         ]
       }
     },
+    "/api/v1/trusted_publishing/github_configs": {
+      "put": {
+        "operationId": "create_trustpub_github_config",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "properties": {
+                  "github_config": {
+                    "$ref": "#/components/schemas/NewGitHubConfig"
+                  }
+                },
+                "required": [
+                  "github_config"
+                ],
+                "type": "object"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "properties": {
+                    "github_config": {
+                      "$ref": "#/components/schemas/GitHubConfig"
+                    }
+                  },
+                  "required": [
+                    "github_config"
+                  ],
+                  "type": "object"
+                }
+              }
+            },
+            "description": "Successful Response"
+          }
+        },
+        "security": [
+          {
+            "cookie": []
+          }
+        ],
+        "summary": "Create a new Trusted Publishing configuration for GitHub Actions.",
+        "tags": [
+          "trusted_publishing"
+        ]
+      }
+    },
     "/api/v1/users/{id}/resend": {
       "put": {
         "operationId": "resend_email_verification",
diff --git a/src/util/errors.rs b/src/util/errors.rs
index fd4f9ea9794..cdc357c18bc 100644
--- a/src/util/errors.rs
+++ b/src/util/errors.rs
@@ -232,6 +232,12 @@ impl From<GitHubError> for BoxedAppError {
     }
 }
 
+impl From<crates_io_trustpub::github::validation::ValidationError> for BoxedAppError {
+    fn from(error: crates_io_trustpub::github::validation::ValidationError) -> Self {
+        bad_request(error)
+    }
+}
+
 // =============================================================================
 // Internal error for use with `chain_error`
 


Reviews:
user: login: eth3lbert
body: I haven't looked at all the other commits yet, but here's some feedback on the GitHub owner and repository validation parts (a bit more hardening).
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:56:27Z
user: login: eth3lbert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T17:06:49Z
user: login: LawnGnome
body: I think @eth3lbert stole my thunder on the only actual things I was going to ask about, so otherwise: LGTM. :zap: 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T23:58:26Z
user: login: LawnGnome
body: Given we're replicating PyPI's rules, which obviously work, I'm good here.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T15:49:40Z

Review comments:
diff_hunk: @@ -0,0 +1,154 @@
+use std::sync::LazyLock;
+
+const MAX_FIELD_LENGTH: usize = 255;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ValidationError {
+    #[error("GitHub repository owner name may not be empty")]
+    OwnerEmpty,
+    #[error("GitHub repository owner name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    OwnerTooLong,
+    #[error("Invalid GitHub repository owner name")]
+    OwnerInvalid,
+
+    #[error("GitHub repository name may not be empty")]
+    RepoEmpty,
+    #[error("GitHub repository name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    RepoTooLong,
+    #[error("Invalid GitHub repository name")]
+    RepoInvalid,
+
+    #[error("Workflow filename may not be empty")]
+    WorkflowFilenameEmpty,
+    #[error("Workflow filename is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    WorkflowFilenameTooLong,
+    #[error("Workflow filename must end with `.yml` or `.yaml`")]
+    WorkflowFilenameMissingSuffix,
+    #[error("Workflow filename must be a filename only, without directories")]
+    WorkflowFilenameContainsSlash,
+
+    #[error("Environment name may not be empty (use `null` to omit)")]
+    EnvironmentEmptyString,
+    #[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    EnvironmentTooLong,
+    #[error("Environment name may not start with whitespace")]
+    EnvironmentStartsWithWhitespace,
+    #[error("Environment name may not end with whitespace")]
+    EnvironmentEndsWithWhitespace,
+    #[error(r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#)]
+    EnvironmentInvalidChars,
+}
+
+pub fn validate_owner(owner: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_OWNER: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$").unwrap());
+
+    if owner.is_empty() {
+        Err(ValidationError::OwnerEmpty)
+    } else if owner.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::OwnerTooLong)
+    } else if !RE_VALID_GITHUB_OWNER.is_match(owner) {
+        Err(ValidationError::OwnerInvalid)
path: crates/crates_io_trustpub/src/github/validation.rs
user: login: eth3lbert
body: This doesn't seem to cover the following cases:

1. Ends with `-`
2. Length exceeds `39`


created_at: 2025-05-06T16:46:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,154 @@
+use std::sync::LazyLock;
+
+const MAX_FIELD_LENGTH: usize = 255;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ValidationError {
+    #[error("GitHub repository owner name may not be empty")]
+    OwnerEmpty,
+    #[error("GitHub repository owner name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    OwnerTooLong,
+    #[error("Invalid GitHub repository owner name")]
+    OwnerInvalid,
+
+    #[error("GitHub repository name may not be empty")]
+    RepoEmpty,
+    #[error("GitHub repository name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    RepoTooLong,
+    #[error("Invalid GitHub repository name")]
+    RepoInvalid,
+
+    #[error("Workflow filename may not be empty")]
+    WorkflowFilenameEmpty,
+    #[error("Workflow filename is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    WorkflowFilenameTooLong,
+    #[error("Workflow filename must end with `.yml` or `.yaml`")]
+    WorkflowFilenameMissingSuffix,
+    #[error("Workflow filename must be a filename only, without directories")]
+    WorkflowFilenameContainsSlash,
+
+    #[error("Environment name may not be empty (use `null` to omit)")]
+    EnvironmentEmptyString,
+    #[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    EnvironmentTooLong,
+    #[error("Environment name may not start with whitespace")]
+    EnvironmentStartsWithWhitespace,
+    #[error("Environment name may not end with whitespace")]
+    EnvironmentEndsWithWhitespace,
+    #[error(r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#)]
+    EnvironmentInvalidChars,
+}
+
+pub fn validate_owner(owner: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_OWNER: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$").unwrap());
+
+    if owner.is_empty() {
+        Err(ValidationError::OwnerEmpty)
+    } else if owner.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::OwnerTooLong)
+    } else if !RE_VALID_GITHUB_OWNER.is_match(owner) {
+        Err(ValidationError::OwnerInvalid)
+    } else {
+        Ok(())
+    }
+}
+
+pub fn validate_repo(repo: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_REPO: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9-_.]+$").unwrap());
+
+    if repo.is_empty() {
+        Err(ValidationError::RepoEmpty)
+    } else if repo.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::RepoTooLong)
+    } else if !RE_VALID_GITHUB_REPO.is_match(repo) {
+        Err(ValidationError::RepoInvalid)
path: crates/crates_io_trustpub/src/github/validation.rs
user: login: eth3lbert
body: 

This doesn't seem to cover the following cases:

1. Reserved names `.` and `..`
2. Length exceeds `100`


created_at: 2025-05-06T16:46:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,154 @@
+use std::sync::LazyLock;
+
+const MAX_FIELD_LENGTH: usize = 255;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ValidationError {
+    #[error("GitHub repository owner name may not be empty")]
+    OwnerEmpty,
+    #[error("GitHub repository owner name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    OwnerTooLong,
+    #[error("Invalid GitHub repository owner name")]
+    OwnerInvalid,
+
+    #[error("GitHub repository name may not be empty")]
+    RepoEmpty,
+    #[error("GitHub repository name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    RepoTooLong,
+    #[error("Invalid GitHub repository name")]
+    RepoInvalid,
+
+    #[error("Workflow filename may not be empty")]
+    WorkflowFilenameEmpty,
+    #[error("Workflow filename is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    WorkflowFilenameTooLong,
+    #[error("Workflow filename must end with `.yml` or `.yaml`")]
+    WorkflowFilenameMissingSuffix,
+    #[error("Workflow filename must be a filename only, without directories")]
+    WorkflowFilenameContainsSlash,
+
+    #[error("Environment name may not be empty (use `null` to omit)")]
+    EnvironmentEmptyString,
+    #[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    EnvironmentTooLong,
+    #[error("Environment name may not start with whitespace")]
+    EnvironmentStartsWithWhitespace,
+    #[error("Environment name may not end with whitespace")]
+    EnvironmentEndsWithWhitespace,
+    #[error(r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#)]
+    EnvironmentInvalidChars,
+}
+
+pub fn validate_owner(owner: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_OWNER: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$").unwrap());
+
+    if owner.is_empty() {
+        Err(ValidationError::OwnerEmpty)
+    } else if owner.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::OwnerTooLong)
+    } else if !RE_VALID_GITHUB_OWNER.is_match(owner) {
+        Err(ValidationError::OwnerInvalid)
+    } else {
+        Ok(())
+    }
+}
+
+pub fn validate_repo(repo: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_REPO: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9-_.]+$").unwrap());
+
+    if repo.is_empty() {
+        Err(ValidationError::RepoEmpty)
+    } else if repo.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::RepoTooLong)
+    } else if !RE_VALID_GITHUB_REPO.is_match(repo) {
+        Err(ValidationError::RepoInvalid)
path: crates/crates_io_trustpub/src/github/validation.rs
user: login: eth3lbert
body: I think the currently proposed regex might be sufficient, as the `regex` crate doesn't support lookaround features. If we'd like to harden this part, although there might be some workarounds for lookaround, I think it would be best implemented with simple string-related checks for maintenance reasons.
created_at: 2025-05-06T17:06:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,154 @@
+use std::sync::LazyLock;
+
+const MAX_FIELD_LENGTH: usize = 255;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ValidationError {
+    #[error("GitHub repository owner name may not be empty")]
+    OwnerEmpty,
+    #[error("GitHub repository owner name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    OwnerTooLong,
+    #[error("Invalid GitHub repository owner name")]
+    OwnerInvalid,
+
+    #[error("GitHub repository name may not be empty")]
+    RepoEmpty,
+    #[error("GitHub repository name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    RepoTooLong,
+    #[error("Invalid GitHub repository name")]
+    RepoInvalid,
+
+    #[error("Workflow filename may not be empty")]
+    WorkflowFilenameEmpty,
+    #[error("Workflow filename is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    WorkflowFilenameTooLong,
+    #[error("Workflow filename must end with `.yml` or `.yaml`")]
+    WorkflowFilenameMissingSuffix,
+    #[error("Workflow filename must be a filename only, without directories")]
+    WorkflowFilenameContainsSlash,
+
+    #[error("Environment name may not be empty (use `null` to omit)")]
+    EnvironmentEmptyString,
+    #[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
+    EnvironmentTooLong,
+    #[error("Environment name may not start with whitespace")]
+    EnvironmentStartsWithWhitespace,
+    #[error("Environment name may not end with whitespace")]
+    EnvironmentEndsWithWhitespace,
+    #[error(r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#)]
+    EnvironmentInvalidChars,
+}
+
+pub fn validate_owner(owner: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_OWNER: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$").unwrap());
+
+    if owner.is_empty() {
+        Err(ValidationError::OwnerEmpty)
+    } else if owner.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::OwnerTooLong)
+    } else if !RE_VALID_GITHUB_OWNER.is_match(owner) {
+        Err(ValidationError::OwnerInvalid)
+    } else {
+        Ok(())
+    }
+}
+
+pub fn validate_repo(repo: &str) -> Result<(), ValidationError> {
+    static RE_VALID_GITHUB_REPO: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9-_.]+$").unwrap());
+
+    if repo.is_empty() {
+        Err(ValidationError::RepoEmpty)
+    } else if repo.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::RepoTooLong)
+    } else if !RE_VALID_GITHUB_REPO.is_match(repo) {
+        Err(ValidationError::RepoInvalid)
+    } else {
+        Ok(())
+    }
+}
+
+pub fn validate_workflow_filename(filename: &str) -> Result<(), ValidationError> {
+    if filename.is_empty() {
+        Err(ValidationError::WorkflowFilenameEmpty)
+    } else if filename.len() > MAX_FIELD_LENGTH {
+        Err(ValidationError::WorkflowFilenameTooLong)
+    } else if !filename.ends_with(".yml") && !filename.ends_with(".yaml") {
+        Err(ValidationError::WorkflowFilenameMissingSuffix)
+    } else if filename.contains('/') {
+        Err(ValidationError::WorkflowFilenameContainsSlash)
+    } else {
+        Ok(())
+    }
+}
+
+pub fn validate_environment(env: &str) -> Result<(), ValidationError> {
+    static RE_INVALID_ENVIRONMENT_CHARS: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new(r#"[\x00-\x1F\x7F'"`,;\\]"#).unwrap());
path: crates/crates_io_trustpub/src/github/validation.rs
user: login: LawnGnome
body: I'm betting you had fun figuring out what "non-printable character" meant in this context (from the GitHub error).
created_at: 2025-05-06T23:45:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+use crate::email::Email;
+
+/// Email template for notifying crate owners about a new crate version
+/// being published.
+#[derive(Debug, Clone)]
+pub struct ConfigCreatedEmail<'a> {
+    pub recipient: &'a str,
+    pub user: &'a str,
+    pub krate: &'a str,
+    pub repository_owner: &'a str,
+    pub repository_name: &'a str,
+    pub workflow_filename: &'a str,
+    pub environment: &'a str,
+}
+
+impl Email for ConfigCreatedEmail<'_> {
+    fn subject(&self) -> String {
+        let Self { krate, .. } = self;
+        format!("crates.io: Trusted Publishing configration added to {krate}")
+    }
+
+    fn body(&self) -> String {
+        let Self {
+            recipient,
+            user,
+            krate,
+            repository_owner,
+            repository_name,
+            workflow_filename,
+            environment,
+        } = self;
+
+        format!(
+            "Hello {recipient}!
+
+crates.io user {user} has added a new \"Trusted Publishing\" configuration for GitHub Actions to a crate that you manage ({krate}). Trusted publishers act as trusted users and can publish new versions of the crate automatically.
+
+Trusted Publishing configuration:
+
+- Repository owner: {repository_owner}
+- Repository name: {repository_name}
+- Workflow filename: {workflow_filename}
+- Environment: {environment}
+
+If you did not make this change and you think it was made maliciously, you can remove the configuration from the crate via the \"Settings\" tab on the crate's page.
+
+If you are unable to revert the change and need to do so, you can email help@crates.io to communicate with the crates.io support team."
path: src/controllers/trustpub/github_configs/emails.rs
user: login: LawnGnome
body: This is fine for now, but we should remember to link this to the documentation we write when this actually gets released.
created_at: 2025-05-06T23:57:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: Turbo87
created_at: 2025-05-07T08:05:09Z
body: @eth3lbert @LawnGnome probably should've mentioned that in the PR message: the validation rules and email template are more or less taken directly from the PyPI implementation 😅 (@woodruffw please let me know if I need to adjust anything to comply with licensing etc.)

I guess technically we don't need the validations at all, since we only need to compare the values with the claims from the OIDC token. They are however useful for:

- avoiding GitHub API calls for user names that are known to be invalid
- avoiding common user mistakes when entering these values

I don't think we need to be quite as strict as GitHub though, so now I'm wondering why PyPI implemented e.g. the non-printable characters validation 🤔 


author_association: MEMBER
user: login: eth3lbert
created_at: 2025-05-07T12:50:36Z
body: > I guess technically we don't need the validations at all, since we only need to compare the values with the claims from the OIDC token. They are however useful for:
> 
>     * avoiding GitHub API calls for user names that are known to be invalid
> 
>     * avoiding common user mistakes when entering these values
> 
> 
> I don't think we need to be quite as strict as GitHub though

Yeah, fair enough. This was something I had in mind before leaving the comments, but I was unsure about the level of validation we should be concerned with, so I just went with a more hardening approach, which wouldn't be wrong, just perhaps unnecessary :D. But I'm genuinely okay with basic validation 👍 
author_association: CONTRIBUTOR
user: login: Turbo87
created_at: 2025-05-07T16:41:22Z
body: @eth3lbert any objections on merging as-is? :)
author_association: MEMBER
user: login: eth3lbert
created_at: 2025-05-07T16:45:58Z
body: > @eth3lbert any objections on merging as-is? :)

No objection! Go ahead :shipit: 
author_association: CONTRIBUTOR