Skip to content

Commit 57870cf

Browse files
authored
Merge pull request #2078 from Kobzol/trusted-publishing
Sync crates.io Trusted Publishing configs
2 parents e82fb46 + 9691eb6 commit 57870cf

File tree

16 files changed

+505
-2
lines changed

16 files changed

+505
-2
lines changed

.github/workflows/main.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232

3333
- name: Install Rust Stable
3434
env:
35-
RUST_VERSION: "1.85.0"
35+
RUST_VERSION: "1.87.0"
3636
run: |
3737
rustc -vV
3838
rustup toolchain install $RUST_VERSION
@@ -103,6 +103,7 @@ jobs:
103103
EMAIL_ENCRYPTION_KEY: ${{ secrets.EMAIL_ENCRYPTION_KEY }}
104104
ZULIP_API_TOKEN: ${{ secrets.ZULIP_API_TOKEN }}
105105
ZULIP_USERNAME: ${{ secrets.ZULIP_USERNAME }}
106+
CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
106107
run: |
107108
cargo run sync apply --src build
108109

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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,16 @@ allowed-merge-teams = ["awesome-team"]
430430
# (optional)
431431
merge-bots = ["homu"]
432432
```
433+
434+
### Crates.io trusted publishing
435+
Configure crates.io Trusted Publishing for crates published from a given repository from GitHub Actions.
436+
437+
```toml
438+
[[crates-io-publishing]]
439+
# Crates that will be published with the given workflow file from this repository (required)
440+
crates = ["regex"]
441+
# Name of a GitHub Actions workflow file that will publish the crate (required)
442+
workflow-filename = "ci.yml"
443+
# GitHub Actions environment that has to be used for the publishing (required)
444+
environment = "deploy"
445+
```

rust_team_data/src/v1.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ pub struct Repo {
174174
pub teams: Vec<RepoTeam>,
175175
pub members: Vec<RepoMember>,
176176
pub branch_protections: Vec<BranchProtection>,
177+
pub crates: Vec<Crate>,
177178
pub archived: bool,
178179
// This attribute is not synced by sync-team.
179180
pub private: bool,
@@ -182,6 +183,12 @@ pub struct Repo {
182183
pub auto_merge_enabled: bool,
183184
}
184185

186+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
187+
pub struct Crate {
188+
pub name: String,
189+
pub crates_io_publishing: Option<CratesIoPublishing>,
190+
}
191+
185192
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
186193
#[serde(rename_all = "kebab-case")]
187194
pub enum Bot {
@@ -244,6 +251,12 @@ pub struct BranchProtection {
244251
pub merge_bots: Vec<MergeBot>,
245252
}
246253

254+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
255+
pub struct CratesIoPublishing {
256+
pub workflow_file: String,
257+
pub environment: String,
258+
}
259+
247260
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248261
pub struct Person {
249262
pub name: String,

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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,8 @@ pub(crate) struct Repo {
807807
pub access: RepoAccess,
808808
#[serde(default)]
809809
pub branch_protections: Vec<BranchProtection>,
810+
#[serde(default)]
811+
pub crates_io_publishing: Vec<CratesIoPublishing>,
810812
}
811813

812814
#[derive(serde_derive::Deserialize, Debug, Clone, PartialEq)]
@@ -865,3 +867,11 @@ pub(crate) struct BranchProtection {
865867
#[serde(default)]
866868
pub merge_bots: Vec<MergeBot>,
867869
}
870+
871+
#[derive(serde_derive::Deserialize, Debug)]
872+
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
873+
pub(crate) struct CratesIoPublishing {
874+
pub crates: Vec<String>,
875+
pub workflow_filename: String,
876+
pub environment: String,
877+
}

src/static_api.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,19 @@ impl<'a> Generator<'a> {
147147
members
148148
},
149149
branch_protections,
150+
crates: r
151+
.crates_io_publishing
152+
.iter()
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+
})
161+
})
162+
.collect(),
150163
archived,
151164
auto_merge_enabled: !managed_by_bors,
152165
};

src/validate.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ static CHECKS: &[Check<fn(&Data, &mut Vec<String>)>] = checks![
5454
validate_repos,
5555
validate_archived_repos,
5656
validate_branch_protections,
57+
validate_trusted_publishing,
5758
validate_member_roles,
5859
validate_admin_access,
5960
validate_website,
@@ -1000,6 +1001,30 @@ Please remove the attributes when using bors"#,
10001001
})
10011002
}
10021003

1004+
/// Validate that trusted publishing configuration has unique crates across all repositories.
1005+
fn validate_trusted_publishing(data: &Data, errors: &mut Vec<String>) {
1006+
let mut crates = HashMap::new();
1007+
wrapper(data.repos(), errors, |repo, _| {
1008+
let repo_name = format!("{}/{}", repo.org, repo.name);
1009+
for publishing in &repo.crates_io_publishing {
1010+
if publishing.crates.is_empty() {
1011+
return Err(anyhow::anyhow!(
1012+
"Repository `{repo_name}` has trusted publishing for an empty set of crates.",
1013+
));
1014+
}
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+
}
1023+
}
1024+
Ok(())
1025+
})
1026+
}
1027+
10031028
/// Enforce that roles are only assigned to a valid team member, and that the
10041029
/// same role id always has a consistent description across teams (because the
10051030
/// role id becomes the Fluent id used for translation).

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)