OXYGENHUB

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