Skip to content

Commit a55bca9

Browse files
committed
Implement synchronization of trusted publishing in crates.io
1 parent 5ec0d5d commit a55bca9

File tree

15 files changed

+458
-38
lines changed

15 files changed

+458
-38
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The repository is automatically synchronized with:
1616
| Zulip user group membership | *Shortly after merge* | [Integration source][sync-team-src] |
1717
| [Governance section on the website][www] | Once per day | [Integration source][www-src] |
1818
| crates.io admin access | 1 hour | [Integration source][crates-io-admin-src] |
19+
| crates.io trusted publishing config | *Shortly after merge* | [Integration source][sync-team-src] |
1920

2021
If you need to add or remove a person from a team, send a PR to this
2122
repository. After it's merged, their account will be added/removed

docs/toml-schema.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -435,11 +435,11 @@ merge-bots = ["homu"]
435435
Configure crates.io Trusted Publishing for crates published from a given repository from GitHub Actions.
436436

437437
```toml
438-
[[trusted-publishing]]
439-
# Name of the crate that will be published from this repository (required)
440-
crate = "regex"
438+
[[crates-io-publishing]]
439+
# Crates that will be published with the given workflow file from this repository (required)
440+
crates = ["regex"]
441441
# Name of a GitHub Actions workflow file that will publish the crate (required)
442442
workflow-filename = "ci.yml"
443-
# GitHub Actions environment that has to be used for the publishing (optional)
443+
# GitHub Actions environment that has to be used for the publishing (required)
444444
environment = "deploy"
445445
```

rust_team_data/src/v1.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ pub struct Repo {
174174
pub teams: Vec<RepoTeam>,
175175
pub members: Vec<RepoMember>,
176176
pub branch_protections: Vec<BranchProtection>,
177-
pub trusted_publishing: Vec<TrustedPublishing>,
177+
pub crates: Vec<Crate>,
178178
pub archived: bool,
179179
// This attribute is not synced by sync-team.
180180
pub private: bool,
@@ -183,6 +183,12 @@ pub struct Repo {
183183
pub auto_merge_enabled: bool,
184184
}
185185

186+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
187+
pub struct Crate {
188+
pub name: String,
189+
pub crates_io_publishing: Option<CratesIoPublishing>,
190+
}
191+
186192
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
187193
#[serde(rename_all = "kebab-case")]
188194
pub enum Bot {
@@ -246,11 +252,9 @@ pub struct BranchProtection {
246252
}
247253

248254
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249-
pub struct TrustedPublishing {
250-
#[serde(rename = "crate")]
251-
pub krate: String,
255+
pub struct CratesIoPublishing {
252256
pub workflow_file: String,
253-
pub environment: Option<String>,
257+
pub environment: String,
254258
}
255259

256260
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ mod schema;
99
mod static_api;
1010
mod validate;
1111

12-
const AVAILABLE_SERVICES: &[&str] = &["github", "mailgun", "zulip"];
12+
const AVAILABLE_SERVICES: &[&str] = &["github", "mailgun", "zulip", "crates-io"];
1313

1414
const USER_AGENT: &str = "https://github.com/rust-lang/team (infra@rust-lang.org)";
1515

src/schema.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -808,7 +808,7 @@ pub(crate) struct Repo {
808808
#[serde(default)]
809809
pub branch_protections: Vec<BranchProtection>,
810810
#[serde(default)]
811-
pub trusted_publishing: Vec<TrustedPublishing>,
811+
pub crates_io_publishing: Vec<CratesIoPublishing>,
812812
}
813813

814814
#[derive(serde_derive::Deserialize, Debug, Clone, PartialEq)]
@@ -870,10 +870,8 @@ pub(crate) struct BranchProtection {
870870

871871
#[derive(serde_derive::Deserialize, Debug)]
872872
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
873-
pub(crate) struct TrustedPublishing {
874-
#[serde(rename = "crate")]
875-
pub krate: String,
873+
pub(crate) struct CratesIoPublishing {
874+
pub crates: Vec<String>,
876875
pub workflow_filename: String,
877-
#[serde(default)]
878-
pub environment: Option<String>,
876+
pub environment: String,
879877
}

src/static_api.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,17 @@ impl<'a> Generator<'a> {
147147
members
148148
},
149149
branch_protections,
150-
trusted_publishing: r
151-
.trusted_publishing
150+
crates: r
151+
.crates_io_publishing
152152
.iter()
153-
.map(|p| v1::TrustedPublishing {
154-
krate: p.krate.clone(),
155-
workflow_file: p.workflow_filename.clone(),
156-
environment: p.environment.clone(),
153+
.flat_map(|p| {
154+
p.crates.iter().map(|krate| v1::Crate {
155+
name: krate.to_string(),
156+
crates_io_publishing: Some(v1::CratesIoPublishing {
157+
workflow_file: p.workflow_filename.clone(),
158+
environment: p.environment.clone(),
159+
}),
160+
})
157161
})
158162
.collect(),
159163
archived,

src/validate.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,13 +1006,20 @@ fn validate_trusted_publishing(data: &Data, errors: &mut Vec<String>) {
10061006
let mut crates = HashMap::new();
10071007
wrapper(data.repos(), errors, |repo, _| {
10081008
let repo_name = format!("{}/{}", repo.org, repo.name);
1009-
for publishing in &repo.trusted_publishing {
1010-
if let Some(prev_crate) = crates.insert(&publishing.krate, repo_name.clone()) {
1009+
for publishing in &repo.crates_io_publishing {
1010+
if publishing.crates.is_empty() {
10111011
return Err(anyhow::anyhow!(
1012-
"Repository `{repo_name}` configures trusted publishing for crate `{}` that is also configured in `{prev_crate}`. Each crate can only be configured once.",
1013-
publishing.krate
1012+
"Repository `{repo_name}` has trusted publishing for an empty set of crates.",
10141013
));
10151014
}
1015+
1016+
for krate in &publishing.crates {
1017+
if let Some(prev_repo) = crates.insert(krate.clone(), repo_name.clone()) {
1018+
return Err(anyhow::anyhow!(
1019+
"Repository `{repo_name}` configures trusted publishing for crate `{krate}` that is also configured in `{prev_repo}`. Each crate can only be configured once.",
1020+
));
1021+
}
1022+
}
10161023
}
10171024
Ok(())
10181025
})

sync-team/src/crates_io/api.rs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
use crate::crates_io::CratesIoPublishingConfig;
2+
use crate::utils::ResponseExt;
3+
use anyhow::{Context, anyhow};
4+
use log::debug;
5+
use reqwest::blocking::Client;
6+
use reqwest::header;
7+
use reqwest::header::{HeaderMap, HeaderValue};
8+
use secrecy::{ExposeSecret, SecretString};
9+
use serde::Serialize;
10+
use std::fmt::{Display, Formatter};
11+
12+
// OpenAPI spec: https://crates.io/api/openapi.json
13+
const CRATES_IO_BASE_URL: &str = "https://crates.io/api/v1";
14+
15+
/// Access to the Zulip API
16+
#[derive(Clone)]
17+
pub(crate) struct CratesIoApi {
18+
client: Client,
19+
token: SecretString,
20+
dry_run: bool,
21+
}
22+
23+
impl CratesIoApi {
24+
pub(crate) fn new(token: SecretString, dry_run: bool) -> Self {
25+
let mut map = HeaderMap::default();
26+
map.insert(
27+
header::USER_AGENT,
28+
HeaderValue::from_static(crate::USER_AGENT),
29+
);
30+
31+
Self {
32+
client: reqwest::blocking::ClientBuilder::default()
33+
.default_headers(map)
34+
.build()
35+
.unwrap(),
36+
token,
37+
dry_run,
38+
}
39+
}
40+
41+
/// List existing trusted publishing configurations for a given crate.
42+
pub(crate) fn list_trusted_publishing_github_configs(
43+
&self,
44+
krate: &str,
45+
) -> anyhow::Result<Vec<TrustedPublishingGitHubConfig>> {
46+
#[derive(serde::Deserialize)]
47+
struct GetTrustedPublishing {
48+
github_configs: Vec<TrustedPublishingGitHubConfig>,
49+
}
50+
51+
let response: GetTrustedPublishing = self
52+
.req::<()>(
53+
reqwest::Method::GET,
54+
&format!("/trusted_publishing/github_configs?crate={krate}"),
55+
None,
56+
)?
57+
.error_for_status()?
58+
.json_annotated()?;
59+
60+
Ok(response.github_configs)
61+
}
62+
63+
/// Create a new trusted publishing configuration for a given crate.
64+
pub(crate) fn create_trusted_publishing_github_config(
65+
&self,
66+
config: &CratesIoPublishingConfig,
67+
) -> anyhow::Result<()> {
68+
debug!(
69+
"Creating trusted publishing config for '{}' in repo '{}/{}', workflow file '{}' and environment '{}'",
70+
config.krate.0,
71+
config.repo_org,
72+
config.repo_name,
73+
config.workflow_file,
74+
config.environment
75+
);
76+
77+
if self.dry_run {
78+
return Ok(());
79+
}
80+
81+
#[derive(serde::Serialize)]
82+
struct TrustedPublishingGitHubConfigCreate<'a> {
83+
repository_owner: &'a str,
84+
repository_name: &'a str,
85+
#[serde(rename = "crate")]
86+
krate: &'a str,
87+
workflow_filename: &'a str,
88+
environment: Option<&'a str>,
89+
}
90+
91+
#[derive(serde::Serialize)]
92+
struct CreateTrustedPublishing<'a> {
93+
github_config: TrustedPublishingGitHubConfigCreate<'a>,
94+
}
95+
96+
let request = CreateTrustedPublishing {
97+
github_config: TrustedPublishingGitHubConfigCreate {
98+
repository_owner: &config.repo_org,
99+
repository_name: &config.repo_name,
100+
krate: &config.krate.0,
101+
workflow_filename: &config.workflow_file,
102+
environment: Some(&config.environment),
103+
},
104+
};
105+
106+
self.req(
107+
reqwest::Method::POST,
108+
"/trusted_publishing/github_configs",
109+
Some(&request),
110+
)?
111+
.error_for_status()
112+
.with_context(|| anyhow!("Cannot created trusted publishing config {config:?}"))?;
113+
114+
Ok(())
115+
}
116+
117+
/// Delete a trusted publishing configuration with the given ID.
118+
pub(crate) fn delete_trusted_publishing_github_config(
119+
&self,
120+
id: TrustedPublishingId,
121+
) -> anyhow::Result<()> {
122+
debug!("Deleting trusted publishing with ID {id}");
123+
124+
if !self.dry_run {
125+
self.req::<()>(
126+
reqwest::Method::DELETE,
127+
&format!("/trusted_publishing/github_configs/{}", id.0),
128+
None,
129+
)?
130+
.error_for_status()
131+
.with_context(|| anyhow!("Cannot delete trusted publishing config with ID {id}"))?;
132+
}
133+
134+
Ok(())
135+
}
136+
137+
/// Perform a request against the crates.io API
138+
fn req<T: Serialize>(
139+
&self,
140+
method: reqwest::Method,
141+
path: &str,
142+
data: Option<&T>,
143+
) -> anyhow::Result<reqwest::blocking::Response> {
144+
let mut req = self
145+
.client
146+
.request(method, format!("{CRATES_IO_BASE_URL}{path}"))
147+
.bearer_auth(self.token.expose_secret());
148+
if let Some(data) = data {
149+
req = req.json(data);
150+
}
151+
152+
Ok(req.send()?)
153+
}
154+
}
155+
156+
#[derive(serde::Deserialize, Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
157+
pub struct TrustedPublishingId(u64);
158+
159+
impl Display for TrustedPublishingId {
160+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
161+
self.0.fmt(f)
162+
}
163+
}
164+
165+
#[derive(serde::Deserialize, Debug)]
166+
pub(crate) struct TrustedPublishingGitHubConfig {
167+
pub(crate) id: TrustedPublishingId,
168+
pub(crate) repository_owner: String,
169+
pub(crate) repository_name: String,
170+
pub(crate) workflow_filename: String,
171+
pub(crate) environment: Option<String>,
172+
}

0 commit comments

Comments
 (0)