Release trust page: badges of what is signed and attested, a graph drawn at its own size bf5101d394c0
A summary row of badges (tag signature, changes signed or sealed, assets built and attested by OxygenFlow); in the graph a lock, ! or a cross on every box with the reason underneath and the details on hover; the builds column only when something was built; asset headings badged. The SVG has its own width and height instead of stretching to the page, and github.css's node rule is scoped to the history graph's shapes, so it no longer strokes the trust graph's text. Test. closes #79
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 12:40 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
bf5101d394c0cae673a730f03a02d7e075a61b751a400c000c72073e4f00b30d- Depends on
- 34f40eb4b205 42fa95540468 749d9a5fcd9f b5668108487f d23464c0ac60 df940d75cd3d
- Depended on by
- 6ff755541a62 2291f8cc9c96
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 30 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A clearer release trust page (#79).** A row of badges at the top says whether the tag is signed and by whom, how many changes are signed or sealed, and how many assets an attested OxygenFlow build produced. The graph is drawn at its own size (it filled the screen), drops the builds column when nothing was built, and marks every box: a lock where the claim is signed and checked, `!` where it is not (unsigned, uploaded by hand), `✘` where it fails, with the reason underneath and the details on hover. Each asset's heading carries its badge too. The GitHub look no longer strokes the graph's text. |
|
| 8 | + | ||
| 7 | 9 | ## 0.3.0 (2026-10-02) |
|
| 8 | 10 | ||
| 9 | 11 | - **Edit a proposal and comment on one from `oh` (#76).** `PATCH /api/v1/repos/{owner}/{name}/proposals/{n}` changes the title or description, for the author or a maintainer as on the page; `oh proposal edit N [-t] [-b | -F FILE]`, `oh proposal comment N -b/-F`, and `oh proposal create -F FILE` ("-" reads stdin). |
|
| … | 29 unchanged lines |
modified assets/github.css +4 −2
| … | 162 unchanged lines |
||
| 163 | 163 | ||
| 164 | 164 | /* History: GitHub's commit-list quiet. */ |
|
| 165 | 165 | :root[data-look="github"] .lane { stroke: var(--line); } |
|
| 166 | - | :root[data-look="github"] .node { fill: var(--panel); stroke: var(--mute); } |
|
| 167 | - | :root[data-look="github"] .node.head { fill: var(--acc); stroke: var(--acc); } |
|
| 166 | + | /* The history and lanes graphs' dots and cards only: other graphs (the |
|
| 167 | + | release trust graph) use .node on groups holding text. */ |
|
| 168 | + | :root[data-look="github"] :is(circle, rect).node { fill: var(--panel); stroke: var(--mute); } |
|
| 169 | + | :root[data-look="github"] :is(circle, rect).node.head { fill: var(--acc); stroke: var(--acc); } |
|
| 168 | 170 | :root[data-look="github"] .chg .title { font-weight: 600; } |
modified crates/hub/src/web/release_trust.rs +156 −53
| … | 113 unchanged lines |
||
| 114 | 114 | } |
|
| 115 | 115 | } |
|
| 116 | 116 | ||
| 117 | + | /// One claim of the trust summary: `pass`, `warn` or `fail`, and what it says. |
|
| 118 | + | pub struct TrustBadge { |
|
| 119 | + | pub class: &'static str, |
|
| 120 | + | pub text: String, |
|
| 121 | + | } |
|
| 122 | + | ||
| 117 | 123 | #[derive(Clone, Debug, Serialize)] |
|
| 118 | 124 | pub struct ReleaseTrust { |
|
| 119 | 125 | pub repository: String, |
|
| … | 20 unchanged lines |
||
| 140 | 146 | Verdict::Warn => "warn", |
|
| 141 | 147 | Verdict::Fail => "fail", |
|
| 142 | 148 | } |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | /// The page's trust summary, one badge per claim: the tag's signature, |
|
| 152 | + | /// how many changes are authenticated, and how many assets an attested |
|
| 153 | + | /// OxygenFlow build produced (oxygenhub#79). |
|
| 154 | + | pub fn summary(&self) -> Vec<TrustBadge> { |
|
| 155 | + | let tag = match self.tag_signature.as_str() { |
|
| 156 | + | "valid" => ("pass", format!("Tag signed by {}", self.tag_signer.as_ref().map(|k| k.name.as_str()).unwrap_or("its signer"))), |
|
| 157 | + | "unsigned" => ("warn", "Tag unsigned".to_string()), |
|
| 158 | + | other => ("fail", format!("Tag signature {other}")), |
|
| 159 | + | }; |
|
| 160 | + | let authenticated: usize = self.authors.iter().map(|a| a.signed + a.sealed).sum(); |
|
| 161 | + | let bad: usize = self.authors.iter().map(|a| a.invalid + a.revoked).sum(); |
|
| 162 | + | let changes = ( |
|
| 163 | + | if bad > 0 { |
|
| 164 | + | "fail" |
|
| 165 | + | } else if authenticated < self.changes { |
|
| 166 | + | "warn" |
|
| 167 | + | } else { |
|
| 168 | + | "pass" |
|
| 169 | + | }, |
|
| 170 | + | format!("{authenticated} of {} changes signed or sealed", self.changes), |
|
| 171 | + | ); |
|
| 172 | + | let attested = |
|
| 173 | + | self.assets.iter().filter(|a| a.build.as_ref().is_some_and(|b| b.receipt.is_some() && b.receipt_problem.is_none())).count(); |
|
| 174 | + | let assets = ( |
|
| 175 | + | if attested == self.assets.len() { "pass" } else { "warn" }, |
|
| 176 | + | if self.assets.is_empty() { |
|
| 177 | + | "No assets".to_string() |
|
| 178 | + | } else { |
|
| 179 | + | format!("{attested} of {} assets built and attested by OxygenFlow", self.assets.len()) |
|
| 180 | + | }, |
|
| 181 | + | ); |
|
| 182 | + | [tag, changes, assets].into_iter().map(|(class, text)| TrustBadge { class, text }).collect() |
|
| 143 | 183 | } |
|
| 144 | 184 | ||
| 145 | 185 | pub fn verdict(&self) -> Verdict { |
|
| … | 298 unchanged lines |
||
| 444 | 484 | out |
|
| 445 | 485 | } |
|
| 446 | 486 | ||
| 447 | - | /// The report as a graph: authors, the tagged state, the builds and the |
|
| 448 | - | /// assets, in four columns, as inline SVG. |
|
| 487 | + | /// The report as a graph: authors, the tagged state, the builds (when any |
|
| 488 | + | /// job built an asset) and the assets, as inline SVG drawn at its own size. |
|
| 489 | + | /// Each node carries a badge: a lock where the claim is signed and checked, |
|
| 490 | + | /// `!` where it is not (unsigned, uploaded by hand), `✘` where it fails; |
|
| 491 | + | /// its second line says why, and its title says it in full (oxygenhub#79). |
|
| 449 | 492 | pub fn graph(t: &ReleaseTrust) -> String { |
|
| 450 | - | const W: f64 = 180.0; |
|
| 451 | - | const H: f64 = 34.0; |
|
| 452 | - | const GAP: f64 = 12.0; |
|
| 453 | - | const COL: f64 = 230.0; |
|
| 493 | + | const W: f64 = 220.0; |
|
| 494 | + | const H: f64 = 40.0; |
|
| 495 | + | const GAP: f64 = 10.0; |
|
| 496 | + | const COL: f64 = W + 48.0; |
|
| 497 | + | struct Node { |
|
| 498 | + | col: usize, |
|
| 499 | + | row: usize, |
|
| 500 | + | title: String, |
|
| 501 | + | sub: String, |
|
| 502 | + | class: &'static str, |
|
| 503 | + | tip: String, |
|
| 504 | + | } |
|
| 454 | 505 | let esc = crate::render::escape; |
|
| 455 | - | let mut nodes: Vec<(f64, f64, String, String, &str)> = Vec::new(); |
|
| 506 | + | let mut nodes: Vec<Node> = Vec::new(); |
|
| 456 | 507 | let mut edges: Vec<(usize, usize, bool)> = Vec::new(); |
|
| 457 | - | let y = |i: usize| 10.0 + i as f64 * (H + GAP); |
|
| 508 | + | let any_build = t.assets.iter().any(|a| a.build.is_some()); |
|
| 509 | + | let asset_col = if any_build { 3 } else { 2 }; |
|
| 458 | 510 | let authors: Vec<usize> = t |
|
| 459 | 511 | .authors |
|
| 460 | 512 | .iter() |
|
| … | 1 unchanged lines |
||
| 462 | 514 | .enumerate() |
|
| 463 | 515 | .map(|(i, a)| { |
|
| 464 | 516 | let name = if a.who.name.is_empty() { a.who.key[..12].to_string() } else { a.who.name.clone() }; |
|
| 465 | - | let ok = a.invalid + a.revoked == 0 && a.unsigned == 0; |
|
| 466 | - | nodes.push(( |
|
| 467 | - | 0.0, |
|
| 468 | - | y(i), |
|
| 469 | - | name, |
|
| 470 | - | format!("{} changes", a.changes), |
|
| 471 | - | if ok { |
|
| 472 | - | "pass" |
|
| 473 | - | } else if a.invalid + a.revoked > 0 { |
|
| 474 | - | "fail" |
|
| 475 | - | } else { |
|
| 476 | - | "warn" |
|
| 477 | - | }, |
|
| 478 | - | )); |
|
| 517 | + | let (class, why) = if a.invalid + a.revoked > 0 { |
|
| 518 | + | ("fail", format!("{} invalid or revoked", a.invalid + a.revoked)) |
|
| 519 | + | } else if a.unsigned > 0 { |
|
| 520 | + | ("warn", format!("{} of {} unsigned", a.unsigned, a.changes)) |
|
| 521 | + | } else { |
|
| 522 | + | ("pass", "all signed".to_string()) |
|
| 523 | + | }; |
|
| 524 | + | let linked = if a.who.account.is_some() { "key linked to an account" } else { "key not linked to an account" }; |
|
| 525 | + | nodes.push(Node { |
|
| 526 | + | col: 0, |
|
| 527 | + | row: i, |
|
| 528 | + | title: name.clone(), |
|
| 529 | + | sub: format!("{} change{} · {why}", a.changes, if a.changes == 1 { "" } else { "s" }), |
|
| 530 | + | class, |
|
| 531 | + | tip: format!("{name}: {} changes, {} signed, {} sealed, {} unsigned; {linked}", a.changes, a.signed, a.sealed, a.unsigned), |
|
| 532 | + | }); |
|
| 479 | 533 | nodes.len() - 1 |
|
| 480 | 534 | }) |
|
| 481 | 535 | .collect(); |
|
| 482 | - | let tag_class = match t.tag_signature.as_str() { |
|
| 483 | - | "valid" => "pass", |
|
| 484 | - | "unsigned" => "warn", |
|
| 485 | - | _ => "fail", |
|
| 536 | + | let signer = t.tag_signer.as_ref().map(|k| k.name.clone()).unwrap_or_default(); |
|
| 537 | + | let (tag_class, tag_sub) = match t.tag_signature.as_str() { |
|
| 538 | + | "valid" => ("pass", format!("signed by {signer}")), |
|
| 539 | + | "unsigned" => ("warn", "unsigned".to_string()), |
|
| 540 | + | other => ("fail", other.to_string()), |
|
| 486 | 541 | }; |
|
| 487 | - | nodes.push((COL, y(0), format!("tag {}", t.tag), format!("{} change{}", t.changes, if t.changes == 1 { "" } else { "s" }), tag_class)); |
|
| 542 | + | nodes.push(Node { |
|
| 543 | + | col: 1, |
|
| 544 | + | row: 0, |
|
| 545 | + | title: format!("tag {}", t.tag), |
|
| 546 | + | sub: tag_sub.clone(), |
|
| 547 | + | class: tag_class, |
|
| 548 | + | tip: format!("tag {}: {} changes; {tag_sub}", t.tag, t.changes), |
|
| 549 | + | }); |
|
| 488 | 550 | let tag = nodes.len() - 1; |
|
| 489 | 551 | for a in authors { |
|
| 490 | 552 | edges.push((a, tag, true)); |
|
| 491 | 553 | } |
|
| 492 | 554 | let mut builds: HashMap<(i64, String), usize> = HashMap::new(); |
|
| 555 | + | let mut build_rows = 0; |
|
| 493 | 556 | for (i, a) in t.assets.iter().enumerate() { |
|
| 494 | 557 | let build = a.build.as_ref().map(|b| { |
|
| 495 | 558 | *builds.entry((b.run, b.job.clone())).or_insert_with(|| { |
|
| 496 | - | let class = match (&b.receipt, &b.receipt_problem) { |
|
| 497 | - | (Some(_), None) => "pass", |
|
| 498 | - | (Some(_), Some(_)) => "fail", |
|
| 499 | - | _ => "warn", |
|
| 559 | + | let runner = b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_default(); |
|
| 560 | + | let (class, why) = match (&b.receipt, &b.receipt_problem) { |
|
| 561 | + | (Some(_), None) => ("pass", format!("receipt verified · {runner}")), |
|
| 562 | + | (Some(_), Some(p)) => ("fail", p.clone()), |
|
| 563 | + | _ => ("warn", "no signed receipt".to_string()), |
|
| 500 | 564 | }; |
|
| 501 | - | let row = nodes.iter().filter(|n| n.0 == 2.0 * COL).count(); |
|
| 502 | - | nodes.push(( |
|
| 503 | - | 2.0 * COL, |
|
| 504 | - | y(row), |
|
| 505 | - | format!("run #{} {}", b.run, b.job), |
|
| 506 | - | b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_else(|| "unsigned".into()), |
|
| 565 | + | nodes.push(Node { |
|
| 566 | + | col: 2, |
|
| 567 | + | row: build_rows, |
|
| 568 | + | title: format!("run #{} {}", b.run, b.job), |
|
| 569 | + | sub: why.clone(), |
|
| 507 | 570 | class, |
|
| 508 | - | )); |
|
| 571 | + | tip: format!( |
|
| 572 | + | "run #{} job {}: {why}{}", |
|
| 573 | + | b.run, |
|
| 574 | + | b.job, |
|
| 575 | + | if b.same_state_as_tag { ", from the tagged state" } else { ", from another state than the tag's" } |
|
| 576 | + | ), |
|
| 577 | + | }); |
|
| 578 | + | build_rows += 1; |
|
| 509 | 579 | edges.push((tag, nodes.len() - 1, b.same_state_as_tag)); |
|
| 510 | 580 | nodes.len() - 1 |
|
| 511 | 581 | }) |
|
| 512 | 582 | }); |
|
| 513 | - | let class = if a.build.is_some() { "pass" } else { "warn" }; |
|
| 514 | - | nodes.push((3.0 * COL, y(i), a.name.clone(), crate::render::bytes(a.size as u64), class)); |
|
| 583 | + | let size = crate::render::bytes(a.size as u64); |
|
| 584 | + | let (class, why) = match &a.build { |
|
| 585 | + | Some(b) if b.receipt.is_some() && b.receipt_problem.is_none() => ("pass", "attested build"), |
|
| 586 | + | Some(_) => ("warn", "built, not attested"), |
|
| 587 | + | None => ("warn", "uploaded by hand"), |
|
| 588 | + | }; |
|
| 589 | + | nodes.push(Node { |
|
| 590 | + | col: asset_col, |
|
| 591 | + | row: i, |
|
| 592 | + | title: a.name.clone(), |
|
| 593 | + | sub: format!("{size} · {why}"), |
|
| 594 | + | class, |
|
| 595 | + | tip: format!("{}: {size}, {why}", a.name), |
|
| 596 | + | }); |
|
| 515 | 597 | if let Some(b) = build { |
|
| 516 | 598 | edges.push((b, nodes.len() - 1, true)); |
|
| 517 | 599 | } |
|
| 518 | 600 | } |
|
| 519 | - | let rows = nodes.iter().map(|n| n.1).fold(0.0, f64::max) + H + 10.0; |
|
| 520 | - | let mut svg = |
|
| 521 | - | format!(r#"<svg class="trust-graph" viewBox="0 0 {} {rows}" role="img" aria-label="Release trust graph">"#, 3.0 * COL + W + 2.0); |
|
| 601 | + | let x = |n: &Node| 1.0 + n.col as f64 * COL; |
|
| 602 | + | let y = |n: &Node| 1.0 + n.row as f64 * (H + GAP); |
|
| 603 | + | let width = 2.0 + asset_col as f64 * COL + W; |
|
| 604 | + | let height = nodes.iter().map(y).fold(0.0, f64::max) + H + 2.0; |
|
| 605 | + | let mut svg = format!( |
|
| 606 | + | r#"<svg class="trust-graph" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="Release trust graph">"# |
|
| 607 | + | ); |
|
| 522 | 608 | for (a, b, solid) in &edges { |
|
| 523 | - | let (x1, y1) = (nodes[*a].0 + W, nodes[*a].1 + H / 2.0); |
|
| 524 | - | let (x2, y2) = (nodes[*b].0, nodes[*b].1 + H / 2.0); |
|
| 609 | + | let (x1, y1) = (x(&nodes[*a]) + W, y(&nodes[*a]) + H / 2.0); |
|
| 610 | + | let (x2, y2) = (x(&nodes[*b]), y(&nodes[*b]) + H / 2.0); |
|
| 525 | 611 | let mx = (x1 + x2) / 2.0; |
|
| 526 | 612 | svg.push_str(&format!( |
|
| 527 | 613 | r#"<path class="edge{}" d="M{x1},{y1} C{mx},{y1} {mx},{y2} {x2},{y2}"/>"#, |
|
| 528 | 614 | if *solid { "" } else { " dashed" } |
|
| 529 | 615 | )); |
|
| 530 | 616 | } |
|
| 531 | - | for (x, y, title, sub, class) in &nodes { |
|
| 617 | + | for n in &nodes { |
|
| 618 | + | let (nx, ny) = (x(n), y(n)); |
|
| 619 | + | // The badge, in a circle at the node's right. |
|
| 620 | + | let (cx, cy) = (nx + W - 16.0, ny + H / 2.0); |
|
| 621 | + | let badge = match n.class { |
|
| 622 | + | // A padlock: the shackle, then the body. |
|
| 623 | + | "pass" => format!( |
|
| 624 | + | r#"<path class="shackle" d="M{},{} v-3 a3.5,3.5 0 0 1 7,0 v3"/><rect class="lock" x="{}" y="{}" width="10" height="7.5" rx="1.5"/>"#, |
|
| 625 | + | cx - 3.5, |
|
| 626 | + | cy - 0.5, |
|
| 627 | + | cx - 5.0, |
|
| 628 | + | cy - 0.5 |
|
| 629 | + | ), |
|
| 630 | + | "warn" => format!(r#"<text class="mark" x="{cx}" y="{}" text-anchor="middle">!</text>"#, cy + 4.0), |
|
| 631 | + | _ => format!(r#"<text class="mark" x="{cx}" y="{}" text-anchor="middle">✘</text>"#, cy + 4.0), |
|
| 632 | + | }; |
|
| 532 | 633 | svg.push_str(&format!( |
|
| 533 | - | r#"<g class="node {class}"><rect x="{x}" y="{y}" width="{W}" height="{H}" rx="4"/><text x="{}" y="{}" class="t">{}</text><text x="{}" y="{}" class="s">{}</text></g>"#, |
|
| 534 | - | x + 8.0, |
|
| 535 | - | y + 14.0, |
|
| 536 | - | esc(&truncate(title, 26)), |
|
| 537 | - | x + 8.0, |
|
| 538 | - | y + 27.0, |
|
| 539 | - | esc(&truncate(sub, 30)), |
|
| 634 | + | r#"<g class="node {}"><title>{}</title><rect x="{nx}" y="{ny}" width="{W}" height="{H}" rx="6"/><circle class="badge" cx="{cx}" cy="{cy}" r="10"/>{badge}<text x="{}" y="{}" class="t">{}</text><text x="{}" y="{}" class="s">{}</text></g>"#, |
|
| 635 | + | n.class, |
|
| 636 | + | esc(&n.tip), |
|
| 637 | + | nx + 10.0, |
|
| 638 | + | ny + 17.0, |
|
| 639 | + | esc(&truncate(&n.title, 28)), |
|
| 640 | + | nx + 10.0, |
|
| 641 | + | ny + 31.0, |
|
| 642 | + | esc(&truncate(&n.sub, 32)), |
|
| 540 | 643 | )); |
|
| 541 | 644 | } |
|
| 542 | 645 | svg.push_str("</svg>"); |
|
| … | 52 unchanged lines |
modified crates/hub/tests/flow.rs +12 −0
| … | 988 unchanged lines |
||
| 989 | 989 | "tool: pinned environment", |
|
| 990 | 990 | "notes.txt: no build found", |
|
| 991 | 991 | "<svg class=\"trust-graph\"", |
|
| 992 | + | // oxygenhub#79: a summary of badges, badges in the graph, and the |
|
| 993 | + | // graph drawn at its own size rather than stretched to the page. |
|
| 994 | + | "Tag signed by", |
|
| 995 | + | "1 of 1 changes signed or sealed", |
|
| 996 | + | "1 of 2 assets built and attested by OxygenFlow", |
|
| 997 | + | "attested build", |
|
| 998 | + | "uploaded by hand", |
|
| 999 | + | "receipt verified", |
|
| 1000 | + | "class=\"shackle\"", |
|
| 1001 | + | "<title>notes.txt:", |
|
| 992 | 1002 | ] { |
|
| 993 | 1003 | assert!(page.contains(want), "missing {want:?}"); |
|
| 994 | 1004 | } |
|
| 1005 | + | let svg = &page[page.find("<svg class=\"trust-graph\"").unwrap()..]; |
|
| 1006 | + | assert!(svg[..200].contains(" width=\"") && svg[..200].contains(" height=\""), "the graph has its own size: {}", &svg[..200]); |
|
| 995 | 1007 | let (_, releases) = call(&h.app, "GET", "/andy/demo/releases", None, None).await; |
|
| 996 | 1008 | assert!(releases.contains("/andy/demo/releases/trust/v1"), "the release links its audit"); |
|
| 997 | 1009 | ||
| … | 435 unchanged lines |
modified templates/release_trust.html +4 −2
| … | 8 unchanged lines |
||
| 9 | 9 | <a class="go small" href="?format=json" download>Export JSON</a></div> |
|
| 10 | 10 | <p class="small mute">Who wrote what this release holds, how it is authenticated, what built each asset and from what, with every claim audited below.{% if let Some(p) = t.since %} Changes are counted since the previous release, {{ p }}.{% endif %} The export carries the signed receipts: check each with <code>oxy receipt verify</code>.</p> |
|
| 11 | 11 | ||
| 12 | + | <ul class="plain trust-summary">{% for b in t.summary() %}<li class="tb tb-{{ b.class }}"><span class="tb-mark" aria-hidden="true">{% if b.class == "pass" %}<svg viewBox="0 0 16 16" width="14" height="14"><path d="M5 7V5a3 3 0 0 1 6 0v2" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3.5" y="7" width="9" height="7" rx="1.5" fill="currentColor"/></svg>{% else if b.class == "warn" %}!{% else %}✘{% endif %}</span>{{ b.text }}</li>{% endfor %}</ul> |
|
| 13 | + | ||
| 12 | 14 | <section class="panel audit audit-{{ t.verdict_class() }}"> |
|
| 13 | 15 | <h2>Audit <span class="small mute">{{ t.audit.len()|plural("check", "checks") }}</span></h2> |
|
| 14 | 16 | <ul class="plain checks">{% for c in t.audit %}<li class="{{ c.class() }}"><span class="glyph">{{ c.glyph() }}</span> <strong>{{ c.title }}</strong>{% if !c.detail.is_empty() %} <span class="small mute">{{ c.detail }}</span>{% endif %}</li>{% endfor %}</ul> |
|
| … | 1 unchanged lines |
||
| 16 | 18 | ||
| 17 | 19 | <section class="panel"> |
|
| 18 | 20 | <h2>Graph</h2> |
|
| 19 | - | <p class="small mute">Authors → the tagged state → the jobs that built the assets → the assets. A dashed line is a build from another state than the tag's.</p> |
|
| 21 | + | <p class="small mute">Authors → the tagged state → the jobs that built the assets → the assets. A lock marks what is signed and checked, <strong>!</strong> what is not (unsigned, or uploaded by hand), <strong>✘</strong> what fails. A dashed line is a build from another state than the tag's. Hover a box for the details.</p> |
|
| 20 | 22 | <div class="trust-graph-wrap">{{ graph|safe }}</div> |
|
| 21 | 23 | </section> |
|
| 22 | 24 | ||
| … | 17 unchanged lines |
||
| 40 | 42 | <h2>Assets</h2> |
|
| 41 | 43 | {% if t.assets.is_empty() %}<p class="mute">This release has no assets.</p>{% endif %} |
|
| 42 | 44 | {% for a in t.assets %} |
|
| 43 | - | <h3><a href="{{ a.url }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.blake3 }}">{{ a.blake3|short }}</code></span></h3> |
|
| 45 | + | <h3><a href="{{ a.url }}">{{ a.name }}</a> {% if let Some(b) = a.build %}{% if b.receipt.is_some() && b.receipt_problem.is_none() %}<span class="tb tb-pass tb-sm">attested build</span>{% else %}<span class="tb tb-warn tb-sm">built, not attested</span>{% endif %}{% else %}<span class="tb tb-warn tb-sm">uploaded by hand</span>{% endif %} <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.blake3 }}">{{ a.blake3|short }}</code></span></h3> |
|
| 44 | 46 | {% if let Some(b) = a.build %} |
|
| 45 | 47 | <dl class="facts"> |
|
| 46 | 48 | <dt>Built by</dt><dd><a href="{{ b.run_url }}">run #{{ b.run }}</a> job <strong>{{ b.job }}</strong>{% if let Some(c) = b.exit_code %}, exit {{ c }}{% endif %}</dd> |
|
| … | 11 unchanged lines |
modified templates/style.css +18 −1
| … | 578 unchanged lines |
||
| 579 | 579 | .audit-warn { border-left: 3px solid var(--warn); } |
|
| 580 | 580 | .audit-pass { border-left: 3px solid var(--ok); } |
|
| 581 | 581 | .trust-graph-wrap { overflow-x: auto; } |
|
| 582 | - | svg.trust-graph { min-width: 720px; width: 100%; height: auto; } |
|
| 582 | + | svg.trust-graph { max-width: 100%; height: auto; display: block; } |
|
| 583 | + | svg.trust-graph .node .badge { fill: var(--bg); stroke: none; } |
|
| 584 | + | svg.trust-graph .node.pass .badge { fill: color-mix(in srgb, var(--ok) 15%, var(--panel)); } |
|
| 585 | + | svg.trust-graph .node.warn .badge { fill: color-mix(in srgb, var(--warn) 18%, var(--panel)); } |
|
| 586 | + | svg.trust-graph .node.fail .badge { fill: color-mix(in srgb, var(--bad) 15%, var(--panel)); } |
|
| 587 | + | svg.trust-graph .node .lock { fill: var(--ok); stroke: none; } |
|
| 588 | + | svg.trust-graph .node .shackle { fill: none; stroke: var(--ok); stroke-width: 1.6; } |
|
| 589 | + | svg.trust-graph .node.warn .mark { fill: var(--warn); font-weight: 700; font-size: 13px; } |
|
| 590 | + | svg.trust-graph .node.fail .mark { fill: var(--bad); font-weight: 700; font-size: 12px; } |
|
| 591 | + | svg.trust-graph text.t { font-weight: 600; } |
|
| 592 | + | /* Trust badges: the page's summary, and the assets' headings. */ |
|
| 593 | + | .trust-summary { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; } |
|
| 594 | + | .tb { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); font-size: 13px; font-weight: 500; } |
|
| 595 | + | .tb-sm { padding: 1px 8px; font-size: 12px; vertical-align: middle; } |
|
| 596 | + | .tb-mark { display: inline-flex; font-weight: 700; } |
|
| 597 | + | .tb-pass { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, var(--line)); background: color-mix(in srgb, var(--ok) 8%, transparent); } |
|
| 598 | + | .tb-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); background: color-mix(in srgb, var(--warn) 8%, transparent); } |
|
| 599 | + | .tb-fail { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, var(--line)); background: color-mix(in srgb, var(--bad) 8%, transparent); } |
|
| 583 | 600 | svg.trust-graph .edge { fill: none; stroke: var(--line); stroke-width: 1.5; } |
|
| 584 | 601 | svg.trust-graph .edge.dashed { stroke-dasharray: 5 4; stroke: var(--warn); } |
|
| 585 | 602 | svg.trust-graph .node rect { fill: var(--panel); stroke: var(--line); } |
|
| … | 62 unchanged lines |