From 7b43d7a3042f41cb25b6749037c9ffa186e7c12d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ber=C3=A1nek?= Date: Thu, 4 Dec 2025 15:32:40 +0100 Subject: [PATCH 1/6] Use `BTreeMap` to make crates.io sync order deterministic --- sync-team/src/crates_io/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sync-team/src/crates_io/mod.rs b/sync-team/src/crates_io/mod.rs index 4cf438489..e799b7f31 100644 --- a/sync-team/src/crates_io/mod.rs +++ b/sync-team/src/crates_io/mod.rs @@ -6,7 +6,7 @@ use std::cmp::Ordering; use crate::crates_io::api::{CratesIoApi, CratesIoCrate, TrustedPublishingGitHubConfig, UserId}; use anyhow::Context; use secrecy::SecretString; -use std::collections::HashMap; +use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] @@ -30,7 +30,7 @@ struct CrateConfig { pub(crate) struct SyncCratesIo { crates_io_api: CratesIoApi, - crates: HashMap, + crates: BTreeMap, user_id: UserId, username: String, } @@ -45,7 +45,7 @@ impl SyncCratesIo { let crates_io_api = CratesIoApi::new(token, dry_run); let user_id = crates_io_api.get_user_id(&username)?; - let crates: HashMap = team_api + let crates: BTreeMap = team_api .get_repos()? .into_iter() .flat_map(|repo| { From 216bb82350dd14ca58982e7c7eff43186d7e374b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ber=C3=A1nek?= Date: Thu, 4 Dec 2025 17:17:48 +0100 Subject: [PATCH 2/6] Rename `[[crates-io-publishing]]` to `[[crates-io]]` --- docs/toml-schema.md | 12 ++++++------ repos/rust-lang/annotate-snippets-rs.toml | 6 +++--- repos/rust-lang/cc-rs.toml | 6 +++--- repos/rust-lang/mdBook.toml | 6 +++--- repos/rust-lang/measureme.toml | 6 +++--- repos/rust-lang/thorin.toml | 6 +++--- src/schema.rs | 6 ++++-- src/static_api.rs | 2 +- src/validate.rs | 2 +- tests/static-api/repos/test-org/some_repo.toml | 6 +++--- 10 files changed, 30 insertions(+), 28 deletions(-) diff --git a/docs/toml-schema.md b/docs/toml-schema.md index 59b8b865d..24e1277ab 100644 --- a/docs/toml-schema.md +++ b/docs/toml-schema.md @@ -445,17 +445,17 @@ 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 diff --git a/repos/rust-lang/annotate-snippets-rs.toml b/repos/rust-lang/annotate-snippets-rs.toml index f1b23d337..d570a33f3 100644 --- a/repos/rust-lang/annotate-snippets-rs.toml +++ b/repos/rust-lang/annotate-snippets-rs.toml @@ -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" diff --git a/repos/rust-lang/cc-rs.toml b/repos/rust-lang/cc-rs.toml index d768a2f73..0a3f40120 100644 --- a/repos/rust-lang/cc-rs.toml +++ b/repos/rust-lang/cc-rs.toml @@ -12,10 +12,10 @@ 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" [[environments]] name = "github-pages" diff --git a/repos/rust-lang/mdBook.toml b/repos/rust-lang/mdBook.toml index 9071d9f5f..03a4ab046 100644 --- a/repos/rust-lang/mdBook.toml +++ b/repos/rust-lang/mdBook.toml @@ -17,7 +17,7 @@ pattern = "master" ci-checks = ["Success gate"] required-approvals = 0 -[[crates-io-publishing]] +[[crates-io]] crates = [ "mdbook", "mdbook-core", @@ -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" diff --git a/repos/rust-lang/measureme.toml b/repos/rust-lang/measureme.toml index b4be858cc..3e717ec48 100644 --- a/repos/rust-lang/measureme.toml +++ b/repos/rust-lang/measureme.toml @@ -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" diff --git a/repos/rust-lang/thorin.toml b/repos/rust-lang/thorin.toml index 6f8afe0a4..a9bcd7184 100644 --- a/repos/rust-lang/thorin.toml +++ b/repos/rust-lang/thorin.toml @@ -9,10 +9,10 @@ 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" [[environments]] name = "publish" diff --git a/src/schema.rs b/src/schema.rs index fbed3397b..ec8069c9d 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -812,7 +812,7 @@ pub(crate) struct Repo { #[serde(default)] pub branch_protections: Vec, #[serde(default)] - pub crates_io_publishing: Vec, + pub crates_io: Vec, #[serde(default)] pub environments: Vec, } @@ -876,9 +876,11 @@ 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, + #[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, diff --git a/src/static_api.rs b/src/static_api.rs index 53e896ec1..c88905e79 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -148,7 +148,7 @@ impl<'a> Generator<'a> { }, branch_protections, crates: r - .crates_io_publishing + .crates_io .iter() .flat_map(|p| { p.crates.iter().map(|krate| v1::Crate { diff --git a/src/validate.rs b/src/validate.rs index 534232d3f..5f422d62e 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -1168,7 +1168,7 @@ fn validate_trusted_publishing(data: &Data, errors: &mut Vec) { 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.", diff --git a/tests/static-api/repos/test-org/some_repo.toml b/tests/static-api/repos/test-org/some_repo.toml index 59b3c6da8..432cb784f 100644 --- a/tests/static-api/repos/test-org/some_repo.toml +++ b/tests/static-api/repos/test-org/some_repo.toml @@ -11,7 +11,7 @@ pattern = "master" ci-checks = ["CI"] allowed-merge-teams = ["foo"] -[[crates-io-publishing]] +[[crates-io]] crates = ["my-crate", "my-crate-2"] -workflow-filename = "ci.yml" -environment = "deploy" +publish-workflow = "ci.yml" +publish-environment = "deploy" From cde284bf5c02744301192c1e4503878c9dcbef41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ber=C3=A1nek?= Date: Thu, 4 Dec 2025 21:55:43 +0100 Subject: [PATCH 3/6] Allow configuring teams that own a crate on crates.io --- docs/toml-schema.md | 4 ++ rust_team_data/src/v1.rs | 8 +++ src/schema.rs | 2 + src/static_api.rs | 49 +++++++++++++------ tests/static-api/_expected/v1/repos.json | 24 ++++++++- .../_expected/v1/repos/some_repo.json | 24 ++++++++- .../static-api/repos/test-org/some_repo.toml | 1 + 7 files changed, 94 insertions(+), 18 deletions(-) diff --git a/docs/toml-schema.md b/docs/toml-schema.md index 24e1277ab..d2ff8c362 100644 --- a/docs/toml-schema.md +++ b/docs/toml-schema.md @@ -459,6 +459,10 @@ 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] diff --git a/rust_team_data/src/v1.rs b/rust_team_data/src/v1.rs index b50eeeb52..e6e480ce3 100644 --- a/rust_team_data/src/v1.rs +++ b/rust_team_data/src/v1.rs @@ -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, pub trusted_publishing_only: bool, + /// GitHub teams that have access to this crate on crates.io + pub teams: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] diff --git a/src/schema.rs b/src/schema.rs index ec8069c9d..6571e9cd2 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -884,6 +884,8 @@ pub(crate) struct CratesIoConfiguration { pub environment: String, #[serde(default = "default_true")] pub disable_other_publish_methods: bool, + #[serde(default)] + pub teams: Vec, } #[derive(serde_derive::Deserialize, Debug, Clone)] diff --git a/src/static_api.rs b/src/static_api.rs index c88905e79..d1e4ef719 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -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; @@ -147,20 +147,41 @@ impl<'a> Generator<'a> { members }, branch_protections, - crates: r - .crates_io - .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::>>()? + }, environments: r .environments .iter() diff --git a/tests/static-api/_expected/v1/repos.json b/tests/static-api/_expected/v1/repos.json index 4dee8934c..e5d0ce51d 100644 --- a/tests/static-api/_expected/v1/repos.json +++ b/tests/static-api/_expected/v1/repos.json @@ -72,7 +72,17 @@ "workflow_file": "ci.yml", "environment": "deploy" }, - "trusted_publishing_only": true + "trusted_publishing_only": true, + "teams": [ + { + "org": "test-org", + "name": "foo" + }, + { + "org": "test-org", + "name": "renamed-team" + } + ] }, { "name": "my-crate-2", @@ -80,7 +90,17 @@ "workflow_file": "ci.yml", "environment": "deploy" }, - "trusted_publishing_only": true + "trusted_publishing_only": true, + "teams": [ + { + "org": "test-org", + "name": "foo" + }, + { + "org": "test-org", + "name": "renamed-team" + } + ] } ], "environments": [], diff --git a/tests/static-api/_expected/v1/repos/some_repo.json b/tests/static-api/_expected/v1/repos/some_repo.json index 603e136d7..380ed1baf 100644 --- a/tests/static-api/_expected/v1/repos/some_repo.json +++ b/tests/static-api/_expected/v1/repos/some_repo.json @@ -40,7 +40,17 @@ "workflow_file": "ci.yml", "environment": "deploy" }, - "trusted_publishing_only": true + "trusted_publishing_only": true, + "teams": [ + { + "org": "test-org", + "name": "foo" + }, + { + "org": "test-org", + "name": "renamed-team" + } + ] }, { "name": "my-crate-2", @@ -48,7 +58,17 @@ "workflow_file": "ci.yml", "environment": "deploy" }, - "trusted_publishing_only": true + "trusted_publishing_only": true, + "teams": [ + { + "org": "test-org", + "name": "foo" + }, + { + "org": "test-org", + "name": "renamed-team" + } + ] } ], "environments": [], diff --git a/tests/static-api/repos/test-org/some_repo.toml b/tests/static-api/repos/test-org/some_repo.toml index 432cb784f..4ba54187f 100644 --- a/tests/static-api/repos/test-org/some_repo.toml +++ b/tests/static-api/repos/test-org/some_repo.toml @@ -15,3 +15,4 @@ allowed-merge-teams = ["foo"] crates = ["my-crate", "my-crate-2"] publish-workflow = "ci.yml" publish-environment = "deploy" +teams = ["foo"] From 0355687fe3fd0eb221e828131456b04d8c488d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ber=C3=A1nek?= Date: Thu, 4 Dec 2025 22:02:49 +0100 Subject: [PATCH 4/6] Add validation --- src/validate.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/validate.rs b/src/validate.rs index 5f422d62e..c85202b80 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -1182,6 +1182,31 @@ fn validate_trusted_publishing(data: &Data, errors: &mut Vec) { )); } } + + 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(()) }) From 1113bbb505a775b7ec1ca050dc00e27e0ecf0161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ber=C3=A1nek?= Date: Thu, 4 Dec 2025 22:04:33 +0100 Subject: [PATCH 5/6] Backfill teams to `cc-rs` and `thorin` --- repos/rust-lang/cc-rs.toml | 1 + repos/rust-lang/thorin.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/repos/rust-lang/cc-rs.toml b/repos/rust-lang/cc-rs.toml index 0a3f40120..aa36ddc93 100644 --- a/repos/rust-lang/cc-rs.toml +++ b/repos/rust-lang/cc-rs.toml @@ -16,6 +16,7 @@ ci-checks = ['Tests pass'] crates = ["cc", "find-msvc-tools"] publish-workflow = "publish.yml" publish-environment = "publish" +teams = ["libs"] [[environments]] name = "github-pages" diff --git a/repos/rust-lang/thorin.toml b/repos/rust-lang/thorin.toml index a9bcd7184..a202f98fc 100644 --- a/repos/rust-lang/thorin.toml +++ b/repos/rust-lang/thorin.toml @@ -13,6 +13,7 @@ pattern = "main" crates = ["thorin-dwp", "thorin-dwp-bin"] publish-workflow = "release-plz.yml" publish-environment = "publish" +teams = ["compiler"] [[environments]] name = "publish" From e41a3503101e7e4f26380d91593433701eb9b6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ber=C3=A1nek?= Date: Thu, 4 Dec 2025 17:12:37 +0100 Subject: [PATCH 6/6] Sync crate ownership on crates.io --- sync-team/src/crates_io/api.rs | 108 +++++++++++++++++++++++++++++++ sync-team/src/crates_io/mod.rs | 115 +++++++++++++++++++++++++++++++-- 2 files changed, 218 insertions(+), 5 deletions(-) diff --git a/sync-team/src/crates_io/api.rs b/sync-team/src/crates_io/api.rs index 9b249b4e5..8682a6306 100644 --- a/sync-team/src/crates_io/api.rs +++ b/sync-team/src/crates_io/api.rs @@ -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> { + #[derive(serde::Deserialize)] + struct OwnersResponse { + users: Vec, + } + + 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::>(); + + 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::>(); + + 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, @@ -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 + } +} diff --git a/sync-team/src/crates_io/mod.rs b/sync-team/src/crates_io/mod.rs index e799b7f31..5e48fcf20 100644 --- a/sync-team/src/crates_io/mod.rs +++ b/sync-team/src/crates_io/mod.rs @@ -3,10 +3,12 @@ mod api; use crate::team_api::TeamApi; use std::cmp::Ordering; -use crate::crates_io::api::{CratesIoApi, CratesIoCrate, TrustedPublishingGitHubConfig, UserId}; +use crate::crates_io::api::{ + CratesIoApi, CratesIoCrate, CratesIoOwner, OwnerKind, TrustedPublishingGitHubConfig, UserId, +}; use anyhow::Context; use secrecy::SecretString; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt::{Display, Formatter}; #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] @@ -18,6 +20,12 @@ impl Display for CrateName { } } +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +struct TeamOwner { + org: String, + name: String, +} + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] struct CrateConfig { krate: CrateName, @@ -26,6 +34,7 @@ struct CrateConfig { workflow_file: String, environment: String, trusted_publishing_only: bool, + teams: Vec, } pub(crate) struct SyncCratesIo { @@ -55,6 +64,7 @@ impl SyncCratesIo { let Some(publishing) = &krate.crates_io_publishing else { return None; }; + Some(( CrateName(krate.name.clone()), CrateConfig { @@ -64,6 +74,15 @@ impl SyncCratesIo { workflow_file: publishing.workflow_file.clone(), environment: publishing.environment.clone(), trusted_publishing_only: krate.trusted_publishing_only, + teams: krate + .teams + .clone() + .into_iter() + .map(|owner| TeamOwner { + org: owner.org, + name: owner.name, + }) + .collect(), }, )) }) @@ -117,6 +136,7 @@ impl SyncCratesIo { // to enable doing a crates.io dry-run without a privileged token. // Because crates.io does not currently support read-only token if !is_ci_dry_run { + // Sync trusted publishing configs let mut empty_vec = vec![]; let configs = tp_configs.get_mut(&krate.0).unwrap_or(&mut empty_vec); @@ -153,6 +173,7 @@ impl SyncCratesIo { config_diffs.extend(configs.iter_mut().map(|c| ConfigDiff::Delete(c.clone()))); } + // Sync "trusted publishing only" crate option let Some(crates_io_crate) = crates.get(&krate.0) else { return Err(anyhow::anyhow!( "Crate `{krate}` is not owned by user `{0}`. Please invite `{0}` to be its owner.", @@ -165,6 +186,48 @@ impl SyncCratesIo { value: desired.trusted_publishing_only, }); } + + // Sync crate owners + let owners = self + .crates_io_api + .list_crate_owners(&krate.0) + .with_context(|| anyhow::anyhow!("Cannot list crate owners of {krate}"))?; + + // Sync team owners + let existing_teams: HashSet = owners + .iter() + .filter(|owner| match owner.kind() { + OwnerKind::User => false, + OwnerKind::Team => true, + }) + .cloned() + .collect(); + let target_teams: HashSet = desired + .teams + .iter() + .map(|team| CratesIoOwner::team(team.org.clone(), team.name.clone())) + .collect(); + let teams_to_add = target_teams + .difference(&existing_teams) + .cloned() + .collect::>(); + if !teams_to_add.is_empty() { + crate_diffs.push(CrateDiff::AddOwners { + krate: krate.to_string(), + owners: teams_to_add, + }); + } + + let teams_to_remove = existing_teams + .difference(&target_teams) + .cloned() + .collect::>(); + if !teams_to_remove.is_empty() { + crate_diffs.push(CrateDiff::RemoveOwners { + krate: krate.to_string(), + owners: teams_to_remove, + }); + } } // If any trusted publishing configs remained in the hashmap, they are leftover and should @@ -174,8 +237,7 @@ impl SyncCratesIo { } // We want to apply deletions first, and only then create new configs, to ensure that we - // don't try to create a duplicate config where e.g. only the environment differs, which - // would be an error in crates.io. + // don't try to create a duplicate config where e.g. only the environment differs. config_diffs.sort_by(|a, b| match &(a, b) { (ConfigDiff::Delete(_), ConfigDiff::Create(_)) => Ordering::Less, (ConfigDiff::Create(_), ConfigDiff::Delete(_)) => Ordering::Greater, @@ -302,7 +364,18 @@ impl std::fmt::Display for ConfigDiff { } enum CrateDiff { - SetTrustedPublishingOnly { krate: String, value: bool }, + SetTrustedPublishingOnly { + krate: String, + value: bool, + }, + AddOwners { + krate: String, + owners: Vec, + }, + RemoveOwners { + krate: String, + owners: Vec, + }, } impl CrateDiff { @@ -311,6 +384,12 @@ impl CrateDiff { Self::SetTrustedPublishingOnly { krate, value } => sync .crates_io_api .set_trusted_publishing_only(krate, *value), + CrateDiff::AddOwners { krate, owners } => { + sync.crates_io_api.invite_crate_owners(krate, owners) + } + CrateDiff::RemoveOwners { krate, owners } => { + sync.crates_io_api.delete_crate_owners(krate, owners) + } } } } @@ -324,6 +403,32 @@ impl std::fmt::Display for CrateDiff { " Setting trusted publishing only option for krate `{krate}` to `{value}`", )?; } + CrateDiff::AddOwners { krate, owners } => { + for owner in owners { + let kind = match owner.kind() { + OwnerKind::User => "user", + OwnerKind::Team => "team", + }; + writeln!( + f, + " Adding `{kind}` owner `{}` to krate `{krate}`", + owner.login() + )?; + } + } + CrateDiff::RemoveOwners { krate, owners } => { + for owner in owners { + let kind = match owner.kind() { + OwnerKind::User => "user", + OwnerKind::Team => "team", + }; + writeln!( + f, + " Removing `{kind}` owner `{}` from krate `{krate}`", + owner.login() + )?; + } + } } Ok(()) }