Release assets link to the receipt that built them 10708ab8f54d
An asset links to the passed Flow job whose artifact has the same BLAKE3, one with a kept signed receipt first: the release page shows the run, job and signed receipt, and the API gives built_by on each asset. Writers can attach an artifact to a release from the run page. closes #39
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 08:16 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
10708ab8f54d3580c7d41a9c1b951ea89da8e12c295c595c6083aa492a80d885- Depends on
- 3f4b0ea6eabf 42fa95540468 93be9ce4653e a3c8288b0141 bf3d07541368 c79a1111dd56 cc19697f8871 d23464c0ac60
- Depended on by
- e4203e141e90 16ee5d19d220 97fc47fee9cf 076c7a7c86b1 107eaba52c9d c8684d44d549 5d90e0e045e2 417184c73f4e 4597750166e7 0b203bbf7cc3 6f52a0419331 554ccacfc123 745d225de344 df940d75cd3d
- 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.1.0 v0.2.0 v0.3.0 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
- 22 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified crates/hub-api/src/lib.rs +17 −0
| … | 821 unchanged lines |
||
| 822 | 822 | pub content_type: String, |
|
| 823 | 823 | pub downloads: i64, |
|
| 824 | 824 | pub url: String, |
|
| 825 | + | /// The OxygenFlow run and job whose artifact has this content, if any. |
|
| 826 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 827 | + | pub built_by: Option<AssetBuild>, |
|
| 828 | + | } |
|
| 829 | + | ||
| 830 | + | /// Where a release asset was built (hub spec §9): a job's artifact with the |
|
| 831 | + | /// same BLAKE3, and that job's signed receipt when one was kept. |
|
| 832 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 833 | + | pub struct AssetBuild { |
|
| 834 | + | pub run: i64, |
|
| 835 | + | pub job: String, |
|
| 836 | + | pub artifact: String, |
|
| 837 | + | pub run_url: String, |
|
| 838 | + | /// The receipt attestation, hex; `None` when the receipt was not signed. |
|
| 839 | + | pub receipt: Option<String>, |
|
| 840 | + | pub receipt_url: Option<String>, |
|
| 841 | + | pub signer: Option<String>, |
|
| 825 | 842 | } |
|
| 826 | 843 | ||
| 827 | 844 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| … | 74 unchanged lines |
modified crates/hub/src/db/flow.rs +27 −0
| … | 481 unchanged lines |
||
| 482 | 482 | pub async fn artifacts(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<Artifact>> { |
|
| 483 | 483 | sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = ? ORDER BY name").bind(job_id).fetch_all(pool).await |
|
| 484 | 484 | } |
|
| 485 | + | ||
| 486 | + | /// Where an artifact with given content came from: the run and job that |
|
| 487 | + | /// built it, and the job's signed receipt when one was kept. |
|
| 488 | + | #[derive(Clone, Debug, FromRow)] |
|
| 489 | + | pub struct Provenance { |
|
| 490 | + | pub run_number: i64, |
|
| 491 | + | pub job: String, |
|
| 492 | + | pub name: String, |
|
| 493 | + | pub receipt_attest: String, |
|
| 494 | + | pub receipt_signer: String, |
|
| 495 | + | } |
|
| 496 | + | ||
| 497 | + | /// The build of the bytes whose BLAKE3 is `content` in repository |
|
| 498 | + | /// `repo_id`: a passed job, one with a kept signed receipt first, newest |
|
| 499 | + | /// run first. |
|
| 500 | + | pub async fn provenance(pool: &Pool, repo_id: i64, content: &str) -> sqlx::Result<Option<Provenance>> { |
|
| 501 | + | sqlx::query_as( |
|
| 502 | + | "SELECT r.number AS run_number, j.name AS job, a.name AS name, j.receipt_attest, j.receipt_signer \ |
|
| 503 | + | FROM flow_artifacts a JOIN flow_jobs j ON j.id = a.job_id JOIN flow_runs r ON r.id = j.run_id \ |
|
| 504 | + | WHERE r.repo_id = ? AND a.content = ? AND j.status = 'passed' \ |
|
| 505 | + | ORDER BY (j.receipt_attest != '') DESC, r.number DESC LIMIT 1", |
|
| 506 | + | ) |
|
| 507 | + | .bind(repo_id) |
|
| 508 | + | .bind(content) |
|
| 509 | + | .fetch_optional(pool) |
|
| 510 | + | .await |
|
| 511 | + | } |
modified crates/hub/src/web/flow.rs +56 −1
| … | 251 unchanged lines |
||
| 252 | 252 | can_rerun: bool, |
|
| 253 | 253 | /// Where a live run stands among the runners' work. |
|
| 254 | 254 | queue: Option<crate::flow::QueueInfo>, |
|
| 255 | + | /// Releases a writer may attach this run's artifacts to. |
|
| 256 | + | releases: Vec<db::releases::Release>, |
|
| 255 | 257 | } |
|
| 256 | 258 | ||
| 257 | 259 | /// Lines of a job's log a page shows before "the rest is loaded as it comes". |
|
| … | 29 unchanged lines |
||
| 287 | 289 | Some(crate::flow::queue_info(&q, row.id, run.id)).filter(|i| i.position.is_some()) |
|
| 288 | 290 | } else { |
|
| 289 | 291 | None |
|
| 292 | + | }; |
|
| 293 | + | let releases = if viewer.account().is_some() && role >= Role::Write && logs.iter().any(|l| !l.artifacts.is_empty()) { |
|
| 294 | + | db::releases::list(&app.db, row.id).await.unwrap_or_default() |
|
| 295 | + | } else { |
|
| 296 | + | vec![] |
|
| 290 | 297 | }; |
|
| 291 | - | Ok(Page(RunPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), run, graph, logs, duration, can_rerun, queue })) |
|
| 298 | + | Ok(Page(RunPage { |
|
| 299 | + | shell, |
|
| 300 | + | repo: RepoShell::new(&app, &row, "flow").with_role(role), |
|
| 301 | + | run, |
|
| 302 | + | graph, |
|
| 303 | + | logs, |
|
| 304 | + | duration, |
|
| 305 | + | can_rerun, |
|
| 306 | + | queue, |
|
| 307 | + | releases, |
|
| 308 | + | })) |
|
| 292 | 309 | } |
|
| 293 | 310 | ||
| 294 | 311 | #[derive(Deserialize)] |
|
| … | 279 unchanged lines |
||
| 574 | 591 | bytes, |
|
| 575 | 592 | ) |
|
| 576 | 593 | .into_response()) |
|
| 594 | + | } |
|
| 595 | + | ||
| 596 | + | #[derive(Deserialize)] |
|
| 597 | + | pub struct AttachForm { |
|
| 598 | + | _csrf: String, |
|
| 599 | + | release: i64, |
|
| 600 | + | } |
|
| 601 | + | ||
| 602 | + | /// `POST /{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/attach`: add |
|
| 603 | + | /// an artifact to a release as an asset, named after its path; the release |
|
| 604 | + | /// page then shows the run and receipt that built it. |
|
| 605 | + | pub async fn attach( |
|
| 606 | + | State(app): State<Shared>, |
|
| 607 | + | viewer: Viewer, |
|
| 608 | + | Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>, |
|
| 609 | + | Form(f): Form<AttachForm>, |
|
| 610 | + | ) -> Result<Response, Error> { |
|
| 611 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}"), &viewer); |
|
| 612 | + | require(&viewer, &shell)?; |
|
| 613 | + | csrf(&viewer, &f._csrf, &shell)?; |
|
| 614 | + | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 615 | + | let run = fdb::run(&app.db, row.id, id) |
|
| 616 | + | .await |
|
| 617 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 618 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 619 | + | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 620 | + | let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) }; |
|
| 621 | + | let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 622 | + | let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) }; |
|
| 623 | + | let releases = db::releases::list(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 624 | + | let Some(rel) = releases.into_iter().find(|r| r.id == f.release) else { return Err(Error::not_found(shell)) }; |
|
| 625 | + | let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) }; |
|
| 626 | + | let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 627 | + | let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string(); |
|
| 628 | + | super::releases::store_asset(&app, rel.id, &file, "application/octet-stream", &bytes) |
|
| 629 | + | .await |
|
| 630 | + | .map_err(|m| Error::bad(shell.clone(), m))?; |
|
| 631 | + | Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", rel.tag)).into_response()) |
|
| 577 | 632 | } |
modified crates/hub/src/web/mod.rs +1 −0
| … | 151 unchanged lines |
||
| 152 | 152 | .route("/{owner}/{name}/flow/at/{r}", get(flow::at)) |
|
| 153 | 153 | .route("/{owner}/{name}/receipts/{attest}", get(flow::receipt_file)) |
|
| 154 | 154 | .route("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}", get(flow::artifact_file)) |
|
| 155 | + | .route("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/attach", post(flow::attach)) |
|
| 155 | 156 | .route("/{owner}/{name}/releases", get(releases::list)) |
|
| 156 | 157 | .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create)) |
|
| 157 | 158 | .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show)) |
|
| … | 431 unchanged lines |
modified crates/hub/src/web/releases.rs +27 −1
| … | 23 unchanged lines |
||
| 24 | 24 | pub heads: Vec<String>, |
|
| 25 | 25 | pub signer: Option<String>, |
|
| 26 | 26 | pub latest: bool, |
|
| 27 | + | /// Where each asset was built, by asset id (hub spec §9). |
|
| 28 | + | pub built: HashMap<i64, db::flow::Provenance>, |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | impl ReleaseView { |
|
| 32 | + | pub fn built_by(&self, asset: &i64) -> Option<&db::flow::Provenance> { |
|
| 33 | + | self.built.get(asset) |
|
| 34 | + | } |
|
| 27 | 35 | } |
|
| 28 | 36 | ||
| 29 | 37 | async fn views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<ReleaseView>, sqlx::Error> { |
|
| … | 7 unchanged lines |
||
| 37 | 45 | let mut out = Vec::new(); |
|
| 38 | 46 | for r in list { |
|
| 39 | 47 | let assets = rdb::assets(&app.db, r.id).await?; |
|
| 48 | + | let mut built = HashMap::new(); |
|
| 49 | + | for a in &assets { |
|
| 50 | + | if let Some(p) = db::flow::provenance(&app.db, row.id, &a.hash).await? { |
|
| 51 | + | built.insert(a.id, p); |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 40 | 54 | let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default(); |
|
| 41 | 55 | let notes_html = super::issues::markdown(app, row, &r.tag, &r.notes).await; |
|
| 42 | - | out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer }); |
|
| 56 | + | out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer, built }); |
|
| 43 | 57 | } |
|
| 44 | 58 | Ok(out) |
|
| 45 | 59 | } |
|
| … | 432 unchanged lines |
||
| 478 | 492 | content_type: a.content_type.clone(), |
|
| 479 | 493 | downloads: a.downloads, |
|
| 480 | 494 | url: format!("{base}/asset/{}/{}", a.id, a.name), |
|
| 495 | + | built_by: v.built_by(&a.id).map(|p| { |
|
| 496 | + | let signed = !p.receipt_attest.is_empty(); |
|
| 497 | + | hub_api::AssetBuild { |
|
| 498 | + | run: p.run_number, |
|
| 499 | + | job: p.job.clone(), |
|
| 500 | + | artifact: p.name.clone(), |
|
| 501 | + | run_url: format!("{base}/flow/{}", p.run_number), |
|
| 502 | + | receipt: signed.then(|| p.receipt_attest.clone()), |
|
| 503 | + | receipt_url: signed.then(|| format!("{base}/receipts/{}", p.receipt_attest)), |
|
| 504 | + | signer: signed.then(|| p.receipt_signer.clone()), |
|
| 505 | + | } |
|
| 506 | + | }), |
|
| 481 | 507 | }) |
|
| 482 | 508 | .collect(), |
|
| 483 | 509 | archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(), |
|
| … | 8 unchanged lines |
modified crates/hub/tests/flow.rs +46 −0
| … | 43 unchanged lines |
||
| 44 | 44 | config.auth.push_tokens = vec!["site".into()]; |
|
| 45 | 45 | config.flow.runner_tokens = vec!["runner-secret".into()]; |
|
| 46 | 46 | config.flow.repos = vec!["andy/*".into()]; |
|
| 47 | + | config.registration.mode = "open".into(); |
|
| 47 | 48 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 48 | 49 | let port = listener.local_addr().unwrap().port(); |
|
| 49 | 50 | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| … | 796 unchanged lines |
||
| 846 | 847 | let (st, got) = get_bytes(&h.app, &link).await; |
|
| 847 | 848 | assert_eq!((st, got == body), (StatusCode::OK, true)); |
|
| 848 | 849 | assert_eq!(get_bytes(&h.app, &format!("/andy/demo/flow/{}/artifacts/build/none", run.number)).await.0, StatusCode::NOT_FOUND); |
|
| 850 | + | ||
| 851 | + | // A writer attaches the artifact to a release from the run page; the |
|
| 852 | + | // release then names the run and job that built it, as does the API. |
|
| 853 | + | let form = |f: String| { |
|
| 854 | + | Request::post("/register").header(header::CONTENT_TYPE, "application/x-www-form-urlencoded").body(Body::from(f)).unwrap() |
|
| 855 | + | }; |
|
| 856 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 857 | + | .oneshot(form("username=andy&email=a%40example.com&password=correct-horse-battery".into())) |
|
| 858 | + | .await |
|
| 859 | + | .unwrap(); |
|
| 860 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 861 | + | let with_cookie = |method: &str, path: &str, body: Option<String>| { |
|
| 862 | + | let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie.clone()); |
|
| 863 | + | if body.is_some() { |
|
| 864 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 865 | + | } |
|
| 866 | + | req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap() |
|
| 867 | + | }; |
|
| 868 | + | let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 869 | + | db::releases::create(&h.app.db, row.id, "v1", "One", "", false, None).await.unwrap(); |
|
| 870 | + | let res = oxygenhub::router(h.app.clone()).oneshot(with_cookie("GET", &format!("/andy/demo/flow/{}", run.number), None)).await.unwrap(); |
|
| 871 | + | let page = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
|
| 872 | + | let attach = format!("/andy/demo/flow/{}/artifacts/build/tool/attach", run.number); |
|
| 873 | + | assert!(page.contains(&attach), "a writer is offered the release"); |
|
| 874 | + | let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 875 | + | let csrf = page[i..].split('"').next().unwrap().to_string(); |
|
| 876 | + | let release_id = db::releases::list(&h.app.db, row.id).await.unwrap()[0].id; |
|
| 877 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 878 | + | .oneshot(with_cookie("POST", &attach, Some(format!("_csrf={csrf}&release={release_id}")))) |
|
| 879 | + | .await |
|
| 880 | + | .unwrap(); |
|
| 881 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER); |
|
| 882 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/releases/tag/v1", None, None).await; |
|
| 883 | + | assert!( |
|
| 884 | + | page.contains(">tool</a>") && page.contains(&format!("built by <a href=\"/andy/demo/flow/{0}\">run #{0}</a> build", run.number)), |
|
| 885 | + | "{page}" |
|
| 886 | + | ); |
|
| 887 | + | let (_, api) = call(&h.app, "GET", "/api/v1/repos/andy/demo/releases", None, None).await; |
|
| 888 | + | let api: serde_json::Value = serde_json::from_str(&api).unwrap(); |
|
| 889 | + | let asset = &api[0]["assets"][0]; |
|
| 890 | + | assert_eq!( |
|
| 891 | + | (asset["name"].as_str(), asset["built_by"]["run"].as_i64(), asset["built_by"]["job"].as_str()), |
|
| 892 | + | (Some("tool"), Some(run.number), Some("build")) |
|
| 893 | + | ); |
|
| 894 | + | assert!(asset["built_by"]["receipt"].is_null(), "that receipt was not signed"); |
|
| 849 | 895 | } |
modified docs/spec.md +2 −0
| … | 387 unchanged lines |
||
| 388 | 388 | ||
| 389 | 389 | Tags come from the repository (spec §10): a name, the state, and a signed attestation when the tagger signed. The tags page lists them with signature status. A release attaches to a tag: notes (Markdown, generated draft from the changes since the previous release), assets (uploaded files, content addressed, size limited by `asset_limit`), a pre-release flag, and a latest marker. Source archives (`.tar.zst`, `.zip`) of the tagged state are generated on demand and cached. |
|
| 390 | 390 | ||
| 391 | + | **Built by.** A release asset links to the build that produced it: the passed OxygenFlow job of this repository with an artifact of the same BLAKE3 (hub spec §13.2). A job with a kept signed receipt comes first, then the newest run. The release page shows the run, the job and the signed receipt with its signer, and the API gives the same as `built_by` on each asset. On a run page, a writer can attach an artifact to a release; it becomes an asset named after the artifact's path, so the link follows by content. A file uploaded by hand links the same way when its bytes match. |
|
| 392 | + | ||
| 391 | 393 | ## 10. Notifications |
|
| 392 | 394 | ||
| 393 | 395 | Events: proposal opened, review requested, review submitted, comment on a thread you are in, mention (`@name`), landing, check failed on your proposal, issue assigned, policy proposal awaiting your signature, push refused. Each becomes an inbox item (`/notifications`, with unread count in the header) and, when the account chooses, an email through `lettre` with a per-account SMTP-independent unsubscribe link. Watching a repository subscribes to all its events; participating in a thread subscribes to it. |
|
| … | 206 unchanged lines |
modified templates/flow_run.html +2 −1
| … | 30 unchanged lines |
||
| 31 | 31 | </section> |
|
| 32 | 32 | {% for l in logs %} |
|
| 33 | 33 | <details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" %} open{% endif %}> |
|
| 34 | - | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {% for a in l.artifacts %}<a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}" title="{{ a.path }}, blob {{ a.blob }}">{{ a.name }}</a> ({{ a.size|bytes }}){% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</summary> |
|
| 34 | + | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}</summary> |
|
| 35 | 35 | {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %} |
|
| 36 | + | {% if !l.artifacts.is_empty() %}<ul class="flow-artifacts small">{% for a in l.artifacts %}<li><a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}">{{ a.name }}</a> <span class="mute">{{ a.path }} · {{ a.size|bytes }} · blob <code title="{{ a.blob }}">{{ a.blob|short }}</code></span>{% if !releases.is_empty() %} <form class="tiny inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}/attach"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="release" aria-label="Release to attach {{ a.name }} to">{% for r in releases %}<option value="{{ r.id }}">{{ r.tag }}</option>{% endfor %}</select> <button>Attach to release</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 36 | 37 | <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span> |
|
| 37 | 38 | {% endfor %}</pre> |
|
| 38 | 39 | {% if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %} |
|
| … | 5 unchanged lines |
modified templates/releases.html +1 −1
| … | 16 unchanged lines |
||
| 17 | 17 | {% if !v.r.notes.is_empty() %}<article class="doc">{{ v.notes_html|safe }}</article>{% endif %} |
|
| 18 | 18 | <h3>Assets</h3> |
|
| 19 | 19 | <ul class="plain"> |
|
| 20 | - | {% for a in v.assets %}<li><a href="{{ repo.url }}/asset/{{ a.id }}/{{ a.name }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.hash }}">{{ a.hash|short }}</code> · {{ a.downloads }} downloads</span> |
|
| 20 | + | {% for a in v.assets %}<li><a href="{{ repo.url }}/asset/{{ a.id }}/{{ a.name }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.hash }}">{{ a.hash|short }}</code> · {{ a.downloads }} downloads</span>{% if let Some(p) = v.built_by(a.id) %} <span class="small" data-built>· built by <a href="{{ repo.url }}/flow/{{ p.run_number }}">run #{{ p.run_number }}</a> {{ p.job }}{% if p.receipt_attest.is_empty() %} <span class="mute">(unsigned receipt)</span>{% else %} · <a href="{{ repo.url }}/receipts/{{ p.receipt_attest }}" title="Check it with oxy receipt verify">signed receipt</a> by {{ p.receipt_signer }}{% endif %}</span>{% endif %} |
|
| 21 | 21 | {% if one && repo.can(crate::auth::Access::Write) %}<form class="tiny" method="post" action="{{ repo.url }}/releases/edit/{{ v.r.id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="asset" value="{{ a.id }}"><button name="action" value="delete_asset">delete</button></form>{% endif %}</li>{% endfor %} |
|
| 22 | 22 | <li><a href="{{ repo.url }}/archive?ref={{ v.r.tag }}&format=tar.zst">Source ({{ v.r.tag }}.tar.zst)</a></li> |
|
| 23 | 23 | <li><a href="{{ repo.url }}/archive?ref={{ v.r.tag }}&format=zip">Source ({{ v.r.tag }}.zip)</a></li> |
|
| … | 18 unchanged lines |
modified templates/style.css +3 −0
| … | 440 unchanged lines |
||
| 441 | 441 | details.flow-job .pad { padding: 0 12px 10px; margin: 0; } |
|
| 442 | 442 | pre.flow-log { margin: 0; padding: 8px 0; max-height: 70vh; overflow: auto; border-top: 1px solid var(--line); background: var(--soft); font-size: 12.5px; line-height: 1.5; } |
|
| 443 | 443 | pre.flow-log:empty { display: none; } |
|
| 444 | + | ul.flow-artifacts { list-style: none; margin: 0; padding: 6px 12px; border-top: 1px solid var(--line); } |
|
| 445 | + | ul.flow-artifacts li { padding: 2px 0; overflow-wrap: anywhere; } |
|
| 446 | + | ul.flow-artifacts select { font-size: 11px; } |
|
| 444 | 447 | pre.flow-log .ln { display: block; padding: 0 12px; white-space: pre-wrap; word-break: break-word; } |
|
| 445 | 448 | pre.flow-log .t { display: inline-block; min-width: 5.5em; color: var(--mute); user-select: none; } |
|
| 446 | 449 | pre.flow-log .s-err { color: color-mix(in srgb, var(--bad) 75%, var(--ink)); } |
|
| … | 75 unchanged lines |