Flow: a failing job's receipt may leave kept artifacts out d29070faef66
A passing job's receipt still names exactly its uploaded artifacts; a failing one's names none, though the job may have kept artifacts with when "always" (oxygenflow#37). Before, such a job's receipt was refused.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 18:59 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d29070faef665c3fd6d41e85daa29259d40eb1e50457bbe3fb68e166ca559c76- Depends on
- 42fa95540468 a3c8288b0141 df940d75cd3d
- Depended on by
- 0468394734e9 a36bab6b9ffc 336c953a020f 0b203bbf7cc3
- 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.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
- 3 atoms, 0 tree operations, 2 files
Browse the tree at this change
modified crates/hub/src/flow.rs +5 −2
| … | 399 unchanged lines |
||
| 400 | 400 | }; |
|
| 401 | 401 | let (receipt, receipt_hash) = match &f.receipt { |
|
| 402 | 402 | Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => { |
|
| 403 | - | // The receipt names exactly the artifacts uploaded, in name order. |
|
| 404 | - | let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect(); |
|
| 403 | + | // A passing job's receipt names exactly the artifacts uploaded, |
|
| 404 | + | // in name order; a failing one's names none, though it may have |
|
| 405 | + | // kept `when "always"` artifacts (oxygenflow#37). |
|
| 406 | + | let kept: Vec<String> = |
|
| 407 | + | if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] }; |
|
| 405 | 408 | if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept { |
|
| 406 | 409 | return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into())); |
|
| 407 | 410 | } |
|
| … | 204 unchanged lines |
modified crates/hub/tests/flow.rs +39 −0
| … | 893 unchanged lines |
||
| 894 | 894 | assert!(asset["built_by"]["receipt"].is_null(), "that receipt was not signed"); |
|
| 895 | 895 | } |
|
| 896 | 896 | ||
| 897 | + | /// oxygenflow#37: a failing job keeps a `when "always"` artifact; its |
|
| 898 | + | /// receipt names none, and the run page says the file is outside it. |
|
| 899 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 900 | + | async fn a_failing_job_keeps_an_artifact_outside_its_receipt() { |
|
| 901 | + | let h = hub().await; |
|
| 902 | + | create(&h.app, "andy", "demo").await; |
|
| 903 | + | let url = format!("{}/andy/demo", h.base); |
|
| 904 | + | let work = tempfile::tempdir().unwrap(); |
|
| 905 | + | let c = client(&url, work.path()); |
|
| 906 | + | write( |
|
| 907 | + | work.path(), |
|
| 908 | + | ".flow/pipeline.kdl", |
|
| 909 | + | &format!( |
|
| 910 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"test\" {{\n sandbox \"alpine\"\n exec \"make\" \"test\"\n output {{\n artifact \"junit\" {{\n path \"/src/out/junit.xml\"\n }}\n }}\n}}\n" |
|
| 911 | + | ), |
|
| 912 | + | ); |
|
| 913 | + | c.snapshot("test").unwrap(); |
|
| 914 | + | c.commit("Test", &[]).unwrap(); |
|
| 915 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 916 | + | let run = runs_of(&h.app, "andy", "demo").await.remove(0); |
|
| 917 | + | let a = claim(&h.app).await.unwrap(); |
|
| 918 | + | let id = a["job_id"].as_i64().unwrap(); |
|
| 919 | + | let (st, text) = upload(&h.app, id, "junit", b"<testsuites/>").await; |
|
| 920 | + | assert_eq!(st, StatusCode::OK, "{text}"); |
|
| 921 | + | let blob = serde_json::from_str::<serde_json::Value>(&text).unwrap()["blob"].as_str().unwrap().to_string(); |
|
| 922 | + | let mut r = receipt("test", a["pipeline_hash"].as_str().unwrap(), 1); |
|
| 923 | + | r["outputs"] = serde_json::json!([blob]); |
|
| 924 | + | assert_eq!( |
|
| 925 | + | finish(&h.app, id, serde_json::json!({ "exit_code": 1, "duration_ms": 700, "receipt": r.clone() })).await, |
|
| 926 | + | StatusCode::BAD_REQUEST, |
|
| 927 | + | "a failing receipt names no outputs" |
|
| 928 | + | ); |
|
| 929 | + | r["outputs"] = serde_json::json!([]); |
|
| 930 | + | assert_eq!(finish(&h.app, id, serde_json::json!({ "exit_code": 1, "duration_ms": 700, "receipt": r })).await, StatusCode::NO_CONTENT); |
|
| 931 | + | let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.number), None, None).await; |
|
| 932 | + | assert!(page.contains(&format!("/andy/demo/flow/{}/artifacts/test/junit", run.number)), "listed"); |
|
| 933 | + | assert!(page.contains("not in its receipt"), "marked"); |
|
| 934 | + | } |
|
| 935 | + | ||
| 897 | 936 | /// A release's trust report (hub spec §9.1): a signed tag, an asset built |
|
| 898 | 937 | /// by a job with a signed receipt from the tagged state, and one uploaded |
|
| 899 | 938 | /// by hand; the page, its JSON export and the API. |
|
| … | 88 unchanged lines |