Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions docs/toml-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,20 +445,24 @@ name = "production"
name = "staging"
```

### Crates.io trusted publishing
Configure crates.io Trusted Publishing for crates published from a given repository from GitHub Actions.
### Crates.io crate management
Configure properties of crates.io crates that are deployed using Trusted Publishing from the given repository.

```toml
[[crates-io-publishing]]
[[crates-io]]
# Crates that will be published with the given workflow file from this repository (required)
crates = ["regex"]
# Name of a GitHub Actions workflow file that will publish the crate (required)
workflow-filename = "ci.yml"
# Name of a GitHub Actions workflow file that will publish the crates (required)
publish-workflow = "ci.yml"
# GitHub Actions environment that has to be used for the publishing (required)
environment = "deploy"
publish-environment = "deploy"
# Disable other mechanisms for publishing this set of crates (optional, default is true)
# If set to `true`, the crates will only be publishable through trusted publishing
disable-other-publish-methods = true
# Set of GitHub teams that will have yank/unyank access to these crates. (optional, defaults to empty array)
# Note that teams can only be specified if `disable-other-publish-methods` is set to `true`,
# as we only want to give teams access if they cannot actually publish new crate versions.
teams = ["awesome-team"]
```

> [!TIP]
Expand Down
6 changes: 3 additions & 3 deletions repos/rust-lang/annotate-snippets-rs.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ pattern = "main"
ci-checks = ["CI"]
required-approvals = 0

[[crates-io-publishing]]
[[crates-io]]
crates = ["annotate-snippets"]
workflow-filename = "release.yml"
environment = "publish"
publish-workflow = "release.yml"
publish-environment = "publish"

[[environments]]
name = "publish"
7 changes: 4 additions & 3 deletions repos/rust-lang/cc-rs.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ pattern = 'main'
required-approvals = 0
ci-checks = ['Tests pass']

[[crates-io-publishing]]
[[crates-io]]
crates = ["cc", "find-msvc-tools"]
workflow-filename = "publish.yml"
environment = "publish"
publish-workflow = "publish.yml"
publish-environment = "publish"
teams = ["libs"]

[[environments]]
name = "github-pages"
Expand Down
6 changes: 3 additions & 3 deletions repos/rust-lang/mdBook.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pattern = "master"
ci-checks = ["Success gate"]
required-approvals = 0

[[crates-io-publishing]]
[[crates-io]]
crates = [
"mdbook",
"mdbook-core",
Expand All @@ -28,8 +28,8 @@ crates = [
"mdbook-renderer",
"mdbook-summary",
]
workflow-filename = "deploy.yml"
environment = "publish"
publish-workflow = "deploy.yml"
publish-environment = "publish"

[[environments]]
name = "github-pages"
Expand Down
6 changes: 3 additions & 3 deletions repos/rust-lang/measureme.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ ci-checks = ["success"]
pattern = "stable"
ci-checks = ["success"]

[[crates-io-publishing]]
[[crates-io]]
crates = ["analyzeme", "decodeme", "measureme"]
workflow-filename = "publish.yml"
environment = "publish"
publish-workflow = "publish.yml"
publish-environment = "publish"

[[environments]]
name = "publish"
Expand Down
7 changes: 4 additions & 3 deletions repos/rust-lang/thorin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ compiler = 'maintain'
[[branch-protections]]
pattern = "main"

[[crates-io-publishing]]
[[crates-io]]
crates = ["thorin-dwp", "thorin-dwp-bin"]
workflow-filename = "release-plz.yml"
environment = "publish"
publish-workflow = "release-plz.yml"
publish-environment = "publish"
teams = ["compiler"]

[[environments]]
name = "publish"
Expand Down
8 changes: 8 additions & 0 deletions rust_team_data/src/v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,19 @@ pub struct Repo {
pub auto_merge_enabled: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CrateTeamOwner {
pub org: String,
pub name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Crate {
pub name: String,
pub crates_io_publishing: Option<CratesIoPublishing>,
pub trusted_publishing_only: bool,
/// GitHub teams that have access to this crate on crates.io
pub teams: Vec<CrateTeamOwner>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
Expand Down
8 changes: 6 additions & 2 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,7 @@ pub(crate) struct Repo {
#[serde(default)]
pub branch_protections: Vec<BranchProtection>,
#[serde(default)]
pub crates_io_publishing: Vec<CratesIoPublishing>,
pub crates_io: Vec<CratesIoConfiguration>,
#[serde(default)]
pub environments: Vec<Environment>,
}
Expand Down Expand Up @@ -876,12 +876,16 @@ pub(crate) struct BranchProtection {

#[derive(serde_derive::Deserialize, Debug)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct CratesIoPublishing {
pub(crate) struct CratesIoConfiguration {
pub crates: Vec<String>,
#[serde(rename = "publish-workflow")]
pub workflow_filename: String,
#[serde(rename = "publish-environment")]
pub environment: String,
#[serde(default = "default_true")]
pub disable_other_publish_methods: bool,
#[serde(default)]
pub teams: Vec<String>,
}

#[derive(serde_derive::Deserialize, Debug, Clone)]
Expand Down
49 changes: 35 additions & 14 deletions src/static_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use anyhow::{ensure, Context as _, Error};
use indexmap::IndexMap;
use log::info;
use rust_team_data::v1;
use rust_team_data::v1::{BranchProtectionMode, RepoMember};
use rust_team_data::v1::{BranchProtectionMode, Crate, CrateTeamOwner, RepoMember};
use std::collections::HashMap;
use std::path::Path;

Expand Down Expand Up @@ -147,20 +147,41 @@ impl<'a> Generator<'a> {
members
},
branch_protections,
crates: r
.crates_io_publishing
.iter()
.flat_map(|p| {
p.crates.iter().map(|krate| v1::Crate {
name: krate.to_string(),
crates_io_publishing: Some(v1::CratesIoPublishing {
workflow_file: p.workflow_filename.clone(),
environment: p.environment.clone(),
}),
trusted_publishing_only: p.disable_other_publish_methods,
crates: {
r.crates_io
.iter()
.flat_map(|p| {
p.crates.iter().map(|krate| {
let mut team_owners = vec![];
for team in &p.teams {
let Some(team) = self.data.team(team) else {
return Err(anyhow::anyhow!("Cannot find team `{team}` that should own krate `{krate}`"))
};
let github_teams =
team.github_teams(self.data).with_context(|| {
format!("failed to get GitHub teams for `{}`", team.name())
})?.into_iter()
.filter(|team| team.org == r.org)
.map(|team| CrateTeamOwner {
org: team.org.to_owned(),
name: team.name.to_owned(),
});
team_owners.extend(github_teams);
}

Ok(v1::Crate {
name: krate.to_string(),
crates_io_publishing: Some(v1::CratesIoPublishing {
workflow_file: p.workflow_filename.clone(),
environment: p.environment.clone(),
}),
trusted_publishing_only: p.disable_other_publish_methods,
teams: team_owners,
})
})
})
})
.collect(),
.collect::<anyhow::Result<Vec<Crate>>>()?
},
environments: r
.environments
.iter()
Expand Down
27 changes: 26 additions & 1 deletion src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1168,7 +1168,7 @@ fn validate_trusted_publishing(data: &Data, errors: &mut Vec<String>) {
let mut crates = HashMap::new();
wrapper(data.repos(), errors, |repo, _| {
let repo_name = format!("{}/{}", repo.org, repo.name);
for publishing in &repo.crates_io_publishing {
for publishing in &repo.crates_io {
if publishing.crates.is_empty() {
return Err(anyhow::anyhow!(
"Repository `{repo_name}` has trusted publishing for an empty set of crates.",
Expand All @@ -1182,6 +1182,31 @@ fn validate_trusted_publishing(data: &Data, errors: &mut Vec<String>) {
));
}
}

if !publishing.teams.is_empty() && !publishing.disable_other_publish_methods {
return Err(anyhow::anyhow!(
"Repository `{repo_name}` configures crates.io access for team(s) `{}` while setting `disable-other-publish-methods = false`. Either remove the team access or set it to `true`.",
publishing.teams.join(", ")
));
}
for team in &publishing.teams {
let Some(team) = data.team(team) else {
return Err(anyhow::anyhow!(
"Repository `{repo_name}` configures crates.io access for team `{team}`, which does not exist.",
));
};
let has_some_gh_team = team
.github_teams(data)?
.into_iter()
.any(|team| team.org == repo.org);
if !has_some_gh_team {
return Err(anyhow::anyhow!(
"Repository `{repo_name}` configures crates.io access for team `{}`, but it has no GitHub teams in organization `{}`.",
team.name(),
repo.org
));
}
}
}
Ok(())
})
Expand Down
108 changes: 108 additions & 0 deletions sync-team/src/crates_io/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,84 @@ impl CratesIoApi {
Ok(configs)
}

/// List owners of a given crate.
pub(crate) fn list_crate_owners(&self, krate: &str) -> anyhow::Result<Vec<CratesIoOwner>> {
#[derive(serde::Deserialize)]
struct OwnersResponse {
users: Vec<CratesIoOwner>,
}

let response: OwnersResponse = self
.req::<()>(
reqwest::Method::GET,
&format!("/crates/{krate}/owners"),
HashMap::new(),
None,
)?
.error_for_status()?
.json_annotated()?;

Ok(response.users)
}

/// Invite the specified user(s) or team(s) to own a given crate.
pub(crate) fn invite_crate_owners(
&self,
krate: &str,
owners: &[CratesIoOwner],
) -> anyhow::Result<()> {
debug!("Inviting owners {owners:?} to crate {krate}");

#[derive(serde::Serialize)]
struct InviteOwnersRequest<'a> {
owners: Vec<&'a str>,
}

let owners = owners.iter().map(|o| o.login.as_str()).collect::<Vec<_>>();

if !self.dry_run {
self.req(
reqwest::Method::PUT,
&format!("/crates/{krate}/owners"),
HashMap::new(),
Some(&InviteOwnersRequest { owners }),
)?
.error_for_status()?;
}

Ok(())
}

/// Delete the specified owner(s) of a given crate.
pub(crate) fn delete_crate_owners(
&self,
krate: &str,
owners: &[CratesIoOwner],
) -> anyhow::Result<()> {
debug!("Deleting owners {owners:?} from crate {krate}");

#[derive(serde::Serialize)]
struct DeleteOwnersRequest<'a> {
owners: &'a [&'a str],
}

let owners = owners.iter().map(|o| o.login.as_str()).collect::<Vec<_>>();

if !self.dry_run {
self.req(
reqwest::Method::DELETE,
&format!("/crates/{krate}/owners"),
HashMap::new(),
Some(&DeleteOwnersRequest { owners: &owners }),
)?
.error_for_status()
.with_context(|| {
anyhow::anyhow!("Cannot delete owner(s) {owners:?} from krate {krate}")
})?;
}
Ok(())
}

/// Create a new trusted publishing configuration for a given crate.
pub(crate) fn create_trusted_publishing_github_config(
&self,
Expand Down Expand Up @@ -323,3 +401,33 @@ pub(crate) struct CratesIoCrate {
#[serde(rename = "trustpub_only")]
pub(crate) trusted_publishing_only: bool,
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq, Hash, Copy, Clone)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum OwnerKind {
User,
Team,
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq, Hash, Clone)]
pub(crate) struct CratesIoOwner {
login: String,
kind: OwnerKind,
}

impl CratesIoOwner {
pub(crate) fn team(org: String, name: String) -> Self {
Self {
login: format!("github:{org}:{name}"),
kind: OwnerKind::Team,
}
}

pub(crate) fn kind(&self) -> OwnerKind {
self.kind
}

pub(crate) fn login(&self) -> &str {
&self.login
}
}
Loading