Skip to content

Commit 757c518

Browse files
authored
Unrolled build for rust-lang#137077
Rollup merge of rust-lang#137077 - Kobzol:citool-test-metrics, r=marcoieni Postprocess bootstrap metrics into GitHub job summary This PR adds a postprocessing step to each CI job that writes the build and test step bootstrap metrics into [GitHub job summary](https://github.blog/news-insights/product-news/supercharging-github-actions-with-job-summaries/). You can see an example result for dist and test jobs [here](https://github.com/rust-lang-ci/rust/actions/runs/13619495480). r? ``@ghost`` try-job: dist-x86_64-illumos try-job: x86_64-gnu
2 parents 08db600 + 7b53ac7 commit 757c518

File tree

8 files changed

+306
-70
lines changed

8 files changed

+306
-70
lines changed

Diff for: .github/workflows/ci.yml

+17
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ jobs:
182182
- name: show the current environment
183183
run: src/ci/scripts/dump-environment.sh
184184

185+
# Pre-build citool before the following step uninstalls rustup
186+
# Build is into the build directory, to avoid modifying sources
187+
- name: build citool
188+
run: |
189+
cd src/ci/citool
190+
CARGO_TARGET_DIR=../../../build/citool cargo build
191+
185192
- name: run the build
186193
# Redirect stderr to stdout to avoid reordering the two streams in the GHA logs.
187194
run: src/ci/scripts/run-build-from-ci.sh 2>&1
@@ -218,6 +225,16 @@ jobs:
218225
# erroring about invalid credentials instead.
219226
if: github.event_name == 'push' || env.DEPLOY == '1' || env.DEPLOY_ALT == '1'
220227

228+
- name: postprocess metrics into the summary
229+
run: |
230+
if [ -f build/metrics.json ]; then
231+
./build/citool/debug/citool postprocess-metrics build/metrics.json ${GITHUB_STEP_SUMMARY}
232+
elif [ -f obj/build/metrics.json ]; then
233+
./build/citool/debug/citool postprocess-metrics obj/build/metrics.json ${GITHUB_STEP_SUMMARY}
234+
else
235+
echo "No metrics.json found"
236+
fi
237+
221238
- name: upload job metrics to DataDog
222239
if: needs.calculate_matrix.outputs.run_type != 'pr'
223240
env:

Diff for: src/bootstrap/src/utils/metrics.rs

+8
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ impl BuildMetrics {
200200
}
201201
};
202202
invocations.push(JsonInvocation {
203+
// The command-line invocation with which bootstrap was invoked.
204+
// Skip the first argument, as it is a potentially long absolute
205+
// path that is not interesting.
206+
cmdline: std::env::args_os()
207+
.skip(1)
208+
.map(|arg| arg.to_string_lossy().to_string())
209+
.collect::<Vec<_>>()
210+
.join(" "),
203211
start_time: state
204212
.invocation_start
205213
.duration_since(SystemTime::UNIX_EPOCH)

Diff for: src/build_helper/src/metrics.rs

+88
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::time::Duration;
2+
13
use serde_derive::{Deserialize, Serialize};
24

35
#[derive(Serialize, Deserialize)]
@@ -12,6 +14,8 @@ pub struct JsonRoot {
1214
#[derive(Serialize, Deserialize)]
1315
#[serde(rename_all = "snake_case")]
1416
pub struct JsonInvocation {
17+
// Remembers the command-line invocation with which bootstrap was invoked.
18+
pub cmdline: String,
1519
// Unix timestamp in seconds
1620
//
1721
// This is necessary to easily correlate this invocation with logs or other data.
@@ -98,3 +102,87 @@ fn null_as_f64_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Err
98102
use serde::Deserialize as _;
99103
Option::<f64>::deserialize(d).map(|f| f.unwrap_or(f64::NAN))
100104
}
105+
106+
/// Represents a single bootstrap step, with the accumulated duration of all its children.
107+
#[derive(Clone, Debug)]
108+
pub struct BuildStep {
109+
pub r#type: String,
110+
pub children: Vec<BuildStep>,
111+
pub duration: Duration,
112+
}
113+
114+
impl BuildStep {
115+
/// Create a `BuildStep` representing a single invocation of bootstrap.
116+
/// The most important thing is that the build step aggregates the
117+
/// durations of all children, so that it can be easily accessed.
118+
pub fn from_invocation(invocation: &JsonInvocation) -> Self {
119+
fn parse(node: &JsonNode) -> Option<BuildStep> {
120+
match node {
121+
JsonNode::RustbuildStep {
122+
type_: kind,
123+
children,
124+
duration_excluding_children_sec,
125+
..
126+
} => {
127+
let children: Vec<_> = children.into_iter().filter_map(parse).collect();
128+
let children_duration = children.iter().map(|c| c.duration).sum::<Duration>();
129+
Some(BuildStep {
130+
r#type: kind.to_string(),
131+
children,
132+
duration: children_duration
133+
+ Duration::from_secs_f64(*duration_excluding_children_sec),
134+
})
135+
}
136+
JsonNode::TestSuite(_) => None,
137+
}
138+
}
139+
140+
let duration = Duration::from_secs_f64(invocation.duration_including_children_sec);
141+
let children: Vec<_> = invocation.children.iter().filter_map(parse).collect();
142+
Self { r#type: "total".to_string(), children, duration }
143+
}
144+
145+
pub fn find_all_by_type(&self, r#type: &str) -> Vec<&Self> {
146+
let mut result = Vec::new();
147+
self.find_by_type(r#type, &mut result);
148+
result
149+
}
150+
151+
fn find_by_type<'a>(&'a self, r#type: &str, result: &mut Vec<&'a Self>) {
152+
if self.r#type == r#type {
153+
result.push(self);
154+
}
155+
for child in &self.children {
156+
child.find_by_type(r#type, result);
157+
}
158+
}
159+
}
160+
161+
/// Writes build steps into a nice indented table.
162+
pub fn format_build_steps(root: &BuildStep) -> String {
163+
use std::fmt::Write;
164+
165+
let mut substeps: Vec<(u32, &BuildStep)> = Vec::new();
166+
167+
fn visit<'a>(step: &'a BuildStep, level: u32, substeps: &mut Vec<(u32, &'a BuildStep)>) {
168+
substeps.push((level, step));
169+
for child in &step.children {
170+
visit(child, level + 1, substeps);
171+
}
172+
}
173+
174+
visit(root, 0, &mut substeps);
175+
176+
let mut output = String::new();
177+
for (level, step) in substeps {
178+
let label = format!(
179+
"{}{}",
180+
".".repeat(level as usize),
181+
// Bootstrap steps can be generic and thus contain angle brackets (<...>).
182+
// However, Markdown interprets these as HTML, so we need to escap ethem.
183+
step.r#type.replace('<', "&lt;").replace('>', "&gt;")
184+
);
185+
writeln!(output, "{label:.<65}{:>8.2}s", step.duration.as_secs_f64()).unwrap();
186+
}
187+
output
188+
}

Diff for: src/ci/citool/Cargo.lock

+9
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,20 @@ version = "1.0.95"
5858
source = "registry+https://github.com/rust-lang/crates.io-index"
5959
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
6060

61+
[[package]]
62+
name = "build_helper"
63+
version = "0.1.0"
64+
dependencies = [
65+
"serde",
66+
"serde_derive",
67+
]
68+
6169
[[package]]
6270
name = "citool"
6371
version = "0.1.0"
6472
dependencies = [
6573
"anyhow",
74+
"build_helper",
6675
"clap",
6776
"insta",
6877
"serde",

Diff for: src/ci/citool/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ serde = { version = "1", features = ["derive"] }
1010
serde_yaml = "0.9"
1111
serde_json = "1"
1212

13+
build_helper = { path = "../../build_helper" }
14+
1315
[dev-dependencies]
1416
insta = "1"
1517

Diff for: src/ci/citool/src/main.rs

+15
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
mod metrics;
2+
13
use std::collections::BTreeMap;
24
use std::path::{Path, PathBuf};
35
use std::process::Command;
@@ -6,6 +8,8 @@ use anyhow::Context;
68
use clap::Parser;
79
use serde_yaml::Value;
810

11+
use crate::metrics::postprocess_metrics;
12+
913
const CI_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
1014
const DOCKER_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../docker");
1115
const JOBS_YML_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../github-actions/jobs.yml");
@@ -338,6 +342,14 @@ enum Args {
338342
#[clap(long = "type", default_value = "auto")]
339343
job_type: JobType,
340344
},
345+
/// Postprocess the metrics.json file generated by bootstrap.
346+
PostprocessMetrics {
347+
/// Path to the metrics.json file
348+
metrics_path: PathBuf,
349+
/// Path to a file where the postprocessed metrics summary will be stored.
350+
/// Usually, this will be GITHUB_STEP_SUMMARY on CI.
351+
summary_path: PathBuf,
352+
},
341353
}
342354

343355
#[derive(clap::ValueEnum, Clone)]
@@ -369,6 +381,9 @@ fn main() -> anyhow::Result<()> {
369381
Args::RunJobLocally { job_type, name } => {
370382
run_workflow_locally(load_db(default_jobs_file)?, job_type, name)?
371383
}
384+
Args::PostprocessMetrics { metrics_path, summary_path } => {
385+
postprocess_metrics(&metrics_path, &summary_path)?;
386+
}
372387
}
373388

374389
Ok(())

Diff for: src/ci/citool/src/metrics.rs

+164
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
use std::collections::BTreeMap;
2+
use std::fs::File;
3+
use std::io::Write;
4+
use std::path::Path;
5+
6+
use anyhow::Context;
7+
use build_helper::metrics::{
8+
BuildStep, JsonNode, JsonRoot, TestOutcome, TestSuite, TestSuiteMetadata, format_build_steps,
9+
};
10+
11+
pub fn postprocess_metrics(metrics_path: &Path, summary_path: &Path) -> anyhow::Result<()> {
12+
let metrics = load_metrics(metrics_path)?;
13+
14+
let mut file = File::options()
15+
.append(true)
16+
.create(true)
17+
.open(summary_path)
18+
.with_context(|| format!("Cannot open summary file at {summary_path:?}"))?;
19+
20+
if !metrics.invocations.is_empty() {
21+
writeln!(file, "# Bootstrap steps")?;
22+
record_bootstrap_step_durations(&metrics, &mut file)?;
23+
record_test_suites(&metrics, &mut file)?;
24+
}
25+
26+
Ok(())
27+
}
28+
29+
fn record_bootstrap_step_durations(metrics: &JsonRoot, file: &mut File) -> anyhow::Result<()> {
30+
for invocation in &metrics.invocations {
31+
let step = BuildStep::from_invocation(invocation);
32+
let table = format_build_steps(&step);
33+
eprintln!("Step `{}`\n{table}\n", invocation.cmdline);
34+
writeln!(
35+
file,
36+
r"<details>
37+
<summary>{}</summary>
38+
<pre><code>{table}</code></pre>
39+
</details>
40+
",
41+
invocation.cmdline
42+
)?;
43+
}
44+
eprintln!("Recorded {} bootstrap invocation(s)", metrics.invocations.len());
45+
46+
Ok(())
47+
}
48+
49+
fn record_test_suites(metrics: &JsonRoot, file: &mut File) -> anyhow::Result<()> {
50+
let suites = get_test_suites(&metrics);
51+
52+
if !suites.is_empty() {
53+
let aggregated = aggregate_test_suites(&suites);
54+
let table = render_table(aggregated);
55+
writeln!(file, "\n# Test results\n")?;
56+
writeln!(file, "{table}")?;
57+
} else {
58+
eprintln!("No test suites found in metrics");
59+
}
60+
61+
Ok(())
62+
}
63+
64+
fn render_table(suites: BTreeMap<String, TestSuiteRecord>) -> String {
65+
use std::fmt::Write;
66+
67+
let mut table = "| Test suite | Passed ✅ | Ignored 🚫 | Failed ❌ |\n".to_string();
68+
writeln!(table, "|:------|------:|------:|------:|").unwrap();
69+
70+
fn write_row(
71+
buffer: &mut String,
72+
name: &str,
73+
record: &TestSuiteRecord,
74+
surround: &str,
75+
) -> std::fmt::Result {
76+
let TestSuiteRecord { passed, ignored, failed } = record;
77+
let total = (record.passed + record.ignored + record.failed) as f64;
78+
let passed_pct = ((*passed as f64) / total) * 100.0;
79+
let ignored_pct = ((*ignored as f64) / total) * 100.0;
80+
let failed_pct = ((*failed as f64) / total) * 100.0;
81+
82+
write!(buffer, "| {surround}{name}{surround} |")?;
83+
write!(buffer, " {surround}{passed} ({passed_pct:.0}%){surround} |")?;
84+
write!(buffer, " {surround}{ignored} ({ignored_pct:.0}%){surround} |")?;
85+
writeln!(buffer, " {surround}{failed} ({failed_pct:.0}%){surround} |")?;
86+
87+
Ok(())
88+
}
89+
90+
let mut total = TestSuiteRecord::default();
91+
for (name, record) in suites {
92+
write_row(&mut table, &name, &record, "").unwrap();
93+
total.passed += record.passed;
94+
total.ignored += record.ignored;
95+
total.failed += record.failed;
96+
}
97+
write_row(&mut table, "Total", &total, "**").unwrap();
98+
table
99+
}
100+
101+
#[derive(Default)]
102+
struct TestSuiteRecord {
103+
passed: u64,
104+
ignored: u64,
105+
failed: u64,
106+
}
107+
108+
fn aggregate_test_suites(suites: &[&TestSuite]) -> BTreeMap<String, TestSuiteRecord> {
109+
let mut records: BTreeMap<String, TestSuiteRecord> = BTreeMap::new();
110+
for suite in suites {
111+
let name = match &suite.metadata {
112+
TestSuiteMetadata::CargoPackage { crates, stage, .. } => {
113+
format!("{} (stage {stage})", crates.join(", "))
114+
}
115+
TestSuiteMetadata::Compiletest { suite, stage, .. } => {
116+
format!("{suite} (stage {stage})")
117+
}
118+
};
119+
let record = records.entry(name).or_default();
120+
for test in &suite.tests {
121+
match test.outcome {
122+
TestOutcome::Passed => {
123+
record.passed += 1;
124+
}
125+
TestOutcome::Failed => {
126+
record.failed += 1;
127+
}
128+
TestOutcome::Ignored { .. } => {
129+
record.ignored += 1;
130+
}
131+
}
132+
}
133+
}
134+
records
135+
}
136+
137+
fn get_test_suites(metrics: &JsonRoot) -> Vec<&TestSuite> {
138+
fn visit_test_suites<'a>(nodes: &'a [JsonNode], suites: &mut Vec<&'a TestSuite>) {
139+
for node in nodes {
140+
match node {
141+
JsonNode::RustbuildStep { children, .. } => {
142+
visit_test_suites(&children, suites);
143+
}
144+
JsonNode::TestSuite(suite) => {
145+
suites.push(&suite);
146+
}
147+
}
148+
}
149+
}
150+
151+
let mut suites = vec![];
152+
for invocation in &metrics.invocations {
153+
visit_test_suites(&invocation.children, &mut suites);
154+
}
155+
suites
156+
}
157+
158+
fn load_metrics(path: &Path) -> anyhow::Result<JsonRoot> {
159+
let metrics = std::fs::read_to_string(path)
160+
.with_context(|| format!("Cannot read JSON metrics from {path:?}"))?;
161+
let metrics: JsonRoot = serde_json::from_str(&metrics)
162+
.with_context(|| format!("Cannot deserialize JSON metrics from {path:?}"))?;
163+
Ok(metrics)
164+
}

0 commit comments

Comments
 (0)