Skip to content

Commit 417e0f9

Browse files
committed
feat(run-environment): support CircleCI for GitHub repositories
The build runs on CircleCI while commits and pull requests live on GitHub, so a run is reported against the GitHub commit and pull request. Detection is `CIRCLECI=true`, and the repository comes from `CIRCLE_REPOSITORY_URL`. CircleCI also builds Bitbucket and GitLab repositories, so the remote's domain is checked and anything but `github.com` is refused with an error naming the domain found. That domain is the only signal available at runtime: `pipeline.project.type` is a pipeline value, interpolated when the config is compiled, so it never reaches the job as an environment variable. `runId` is `CIRCLE_WORKFLOW_ID`, shared by every job and every parallel container of a workflow; the per-job `CIRCLE_WORKFLOW_JOB_ID` would split one workflow into unrelated runs. `runPartId` is `{CIRCLE_JOB}-{CIRCLE_NODE_INDEX}`, to stay unique along both fan-out axes. No commit hash override is needed, as CircleCI checks out the associated commit rather than a synthetic merge ref. Uploads authenticate with an OIDC token the job mints for itself, so they need no `CODSPEED_TOKEN` secret. The token CircleCI exposes in `CIRCLE_OIDC_TOKEN` and `CIRCLE_OIDC_TOKEN_V2` cannot be used: its audience is fixed to the id of the CircleCI organization, while CodSpeed requires its own. Only the `circleci` CLI can request a custom audience — CircleCI publishes no endpoint for it, unlike the `ACTIONS_ID_TOKEN_REQUEST_URL` GitHub Actions provides — so the runner shells out to `circleci run oidc get`, once per upload, as a token expires an hour after it is minted. Whether a job can mint at all is settled by minting a token and throwing it away, before the benchmarks run: a CLI can be installed and still predate `run oidc get`, so probing for the binary would pass and the mint would fail once the benchmarks had run for nothing. A static `CODSPEED_TOKEN` is used as is when one is set, and stays required for a pull request opened from a fork, whose token names the fork. `baseRef` and `sender` are left empty: CircleCI exposes neither a base-branch variable nor the id the repository provider gives the user who triggered a build. Closes COD-2995 Closes COD-3257
1 parent 97a789a commit 417e0f9

9 files changed

Lines changed: 778 additions & 0 deletions
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use console::style;
2+
use log::*;
3+
use simplelog::SharedLogger;
4+
use std::{env, io::Write};
5+
6+
use crate::{
7+
logger::{GroupEvent, get_announcement_event, get_group_event, get_json_event},
8+
run_environment::logger::should_provider_logger_handle_record,
9+
};
10+
11+
/// A logger that prints logs in the format expected by CircleCI
12+
///
13+
/// CircleCI has no collapsible section markers, so groups are printed as plain
14+
/// headers.
15+
pub struct CircleCILogger {
16+
log_level: LevelFilter,
17+
}
18+
19+
impl CircleCILogger {
20+
pub fn new() -> Self {
21+
// force activation of colors: CircleCI renders ANSI sequences in its UI, but
22+
// the output is not a TTY so colors would be disabled by default.
23+
console::set_colors_enabled(true);
24+
25+
let log_level = env::var("CODSPEED_LOG")
26+
.ok()
27+
.and_then(|log_level| log_level.parse::<log::LevelFilter>().ok())
28+
.unwrap_or(log::LevelFilter::Info);
29+
Self { log_level }
30+
}
31+
}
32+
33+
impl Log for CircleCILogger {
34+
fn enabled(&self, _metadata: &Metadata) -> bool {
35+
true
36+
}
37+
38+
fn log(&self, record: &Record) {
39+
if !should_provider_logger_handle_record(record) {
40+
return;
41+
}
42+
43+
let level = record.level();
44+
let message = record.args();
45+
46+
if let Some(group_event) = get_group_event(record) {
47+
match group_event {
48+
GroupEvent::Start(name) | GroupEvent::StartOpened(name) => {
49+
println!("{}", style(name).cyan().bold());
50+
}
51+
GroupEvent::End => {}
52+
}
53+
return;
54+
}
55+
56+
if get_json_event(record).is_some() {
57+
return;
58+
}
59+
60+
if let Some(announcement) = get_announcement_event(record) {
61+
println!("{}", style(announcement).green());
62+
return;
63+
}
64+
65+
if level > self.log_level {
66+
return;
67+
}
68+
69+
match level {
70+
Level::Error => {
71+
println!("{}", style(message).red());
72+
}
73+
Level::Warn => {
74+
println!("{}", style(message).yellow());
75+
}
76+
Level::Info => {
77+
println!("{message}");
78+
}
79+
Level::Debug => {
80+
println!("{}", style(message).cyan());
81+
}
82+
Level::Trace => {
83+
println!("{}", style(message).magenta());
84+
}
85+
}
86+
}
87+
88+
fn flush(&self) {
89+
std::io::stdout().flush().unwrap();
90+
}
91+
}
92+
93+
impl SharedLogger for CircleCILogger {
94+
fn level(&self) -> LevelFilter {
95+
self.log_level
96+
}
97+
98+
fn config(&self) -> Option<&simplelog::Config> {
99+
None
100+
}
101+
102+
fn as_log(self: Box<Self>) -> Box<dyn Log> {
103+
Box::new(*self)
104+
}
105+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
mod logger;
2+
mod oidc;
3+
mod provider;
4+
5+
pub use provider::CircleCIProvider;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use std::process::Command;
2+
3+
use crate::prelude::*;
4+
5+
/// The CLI CircleCI makes available inside jobs.
6+
const CIRCLECI_CLI: &str = "circleci";
7+
8+
/// Mints an OIDC token for `audience`.
9+
///
10+
/// The token CircleCI puts in `CIRCLE_OIDC_TOKEN` and `CIRCLE_OIDC_TOKEN_V2` cannot
11+
/// be used instead: its audience is the id of the CircleCI organization, while
12+
/// CodSpeed requires its own. Requesting the audience is what makes a token minted
13+
/// for another integration unusable against CodSpeed, and vice versa.
14+
///
15+
/// Errors carry what the CLI itself reported, as only the first error of a chain is
16+
/// shown outside of debug logging: callers should add their advice to that message
17+
/// rather than wrap it.
18+
///
19+
/// <https://circleci.com/docs/guides/permissions-authentication/oidc-tokens-with-custom-claims/>
20+
pub fn mint_token(audience: &str) -> Result<String> {
21+
let claims = serde_json::json!({ "aud": audience }).to_string();
22+
23+
let output = Command::new(CIRCLECI_CLI)
24+
.args(["run", "oidc", "get", "--claims", &claims])
25+
.output()
26+
.map_err(|error| anyhow!("Failed to run the `circleci` CLI: {error}"))?;
27+
28+
if !output.status.success() {
29+
bail!(
30+
"`circleci run oidc get` failed: {}",
31+
String::from_utf8_lossy(&output.stderr).trim()
32+
);
33+
}
34+
35+
// CircleCI does not mask tokens minted this way in the job output, so the token
36+
// must not reach the logs, here or in the callers.
37+
let token = String::from_utf8(output.stdout)
38+
.map_err(|_| anyhow!("The OIDC token minted by CircleCI is not valid UTF-8"))?
39+
.trim()
40+
.to_string();
41+
42+
if token.is_empty() {
43+
bail!("`circleci run oidc get` returned an empty token");
44+
}
45+
46+
Ok(token)
47+
}

0 commit comments

Comments
 (0)