OXYGENHUB

Job artifacts: output declarations, extraction and upload a67b97e9a717

A job may declare up to 16 artifacts, output { artifact "name" { path
"/src/…" } }, part of its hash only when present; paths on tmpfs mounts
are refused. After a passing job the runner reads each artifact from the
job's disk with debugfs, uploads it, checks the hub kept the same blob, and
names the blobs in the receipt's outputs. An artifact that cannot be kept
fails the job, without a receipt.

closes #11
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 07:54 UTC
Signature
✔ signed by Andy McCall
Hash
a67b97e9a71707e2155e2f6ebc0f6a1cff98faa39a190ce378fb70d2c1246635
Depends on
47c1d64dec8a a72abeeedd78 e0918b887850 fc10d9e80383
Depended on by
3554afefb6aa f201e1bc5bcd 5cf1e8aefbb6 e87c1c138187 f4e51e63582d e20897216221 46fe2c815cc3 89171fca079c d60aac6c3f8a 8f211e20bea5 6d53fb7d00ec 9a6fac2b40a9 ea56c56eaf90 720eb8191059 e2c125610f43 280ef951d193 bf7bb1b80fa3 02b127d2c721 3007d8f011fa e576e08617dc b647ab72d51e
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
21 atoms, 1 tree operation, 7 files

Browse the tree at this change

added crates/flow/src/outputs.rs +104 −0
1 +
//! A job's artifacts (spec §5.2): read from its disks after the microVM
2 +
//! has stopped, with debugfs, so the runner needs no mount rights.
3 +
4 +
use anyhow::{bail, Context, Result};
5 +
use flow_core::{Job, MountSource};
6 +
use std::path::{Path, PathBuf};
7 +
8 +
/// Largest artifact a runner takes: 512 MiB.
9 +
pub const MAX_ARTIFACT_BYTES: u64 = 512 << 20;
10 +
11 +
/// Where guest path `path` lives on the host: the disk image, and the path
12 +
/// inside its file system. The workspace is `/src`; each `ephemeral://disk`
13 +
/// mount is `mount-N.ext4` in `scratch`, numbered as `microvm::run` does.
14 +
pub fn locate(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
15 +
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
16 +
        if let Some(rest) = path.strip_prefix(&format!("{}/", m.target)) {
17 +
            return (scratch.join(format!("mount-{i}.ext4")), format!("/{rest}"));
18 +
        }
19 +
    }
20 +
    (workspace.to_path_buf(), path.strip_prefix("/src").unwrap_or(path).to_string())
21 +
}
22 +
23 +
fn debugfs(image: &Path, request: &str) -> Result<String> {
24 +
    let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?;
25 +
    if !out.status.success() {
26 +
        bail!("debugfs {request:?}: {}", String::from_utf8_lossy(&out.stderr).trim());
27 +
    }
28 +
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
29 +
}
30 +
31 +
/// Read regular file `inner` of ext4 image `image`, at most
32 +
/// [`MAX_ARTIFACT_BYTES`]. Paths come from a checked pipeline: absolute,
33 +
/// clean, and free of quotes and spaces.
34 +
pub fn read(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<u8>> {
35 +
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
36 +
    if !stat.contains("Type: regular") {
37 +
        bail!("{inner} is not a regular file in the job's disk (it may be missing or a link)");
38 +
    }
39 +
    let size: u64 = stat
40 +
        .split("Size: ")
41 +
        .nth(1)
42 +
        .and_then(|s| s.split_whitespace().next())
43 +
        .and_then(|s| s.parse().ok())
44 +
        .context("debugfs gave no size")?;
45 +
    if size > MAX_ARTIFACT_BYTES {
46 +
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", size >> 20, MAX_ARTIFACT_BYTES >> 20);
47 +
    }
48 +
    let dest = scratch.join("artifact.out");
49 +
    let _ = std::fs::remove_file(&dest);
50 +
    debugfs(image, &format!("dump \"{inner}\" \"{}\"", dest.display()))?;
51 +
    let bytes = std::fs::read(&dest).with_context(|| format!("{inner}: debugfs wrote nothing"))?;
52 +
    let _ = std::fs::remove_file(&dest);
53 +
    if bytes.len() as u64 != size {
54 +
        bail!("{inner}: read {} bytes of {size}", bytes.len());
55 +
    }
56 +
    Ok(bytes)
57 +
}
58 +
59 +
#[cfg(test)]
60 +
mod tests {
61 +
    use super::*;
62 +
    use flow_core::Mount;
63 +
64 +
    fn job() -> Job {
65 +
        Job {
66 +
            name: "build".into(),
67 +
            sandbox: "rust".into(),
68 +
            scope: vec![],
69 +
            depends_on: vec![],
70 +
            env: Default::default(),
71 +
            exec: vec!["make".into()],
72 +
            mounts: vec![
73 +
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64 },
74 +
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024 },
75 +
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024 },
76 +
            ],
77 +
            outputs: vec![],
78 +
        }
79 +
    }
80 +
81 +
    #[test]
82 +
    fn paths_are_found_on_the_disk_that_holds_them() {
83 +
        let (w, s) = (Path::new("/w.ext4"), Path::new("/scratch"));
84 +
        assert_eq!(locate(&job(), w, s, "/src/target/release/oxy"), (s.join("mount-0.ext4"), "/release/oxy".into()));
85 +
        assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into()));
86 +
        assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into()));
87 +
        assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent");
88 +
    }
89 +
90 +
    #[test]
91 +
    fn files_are_read_back_from_an_ext4_image() {
92 +
        let dir = tempfile::tempdir().unwrap();
93 +
        let tree = dir.path().join("tree");
94 +
        std::fs::create_dir_all(tree.join("release")).unwrap();
95 +
        let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
96 +
        std::fs::write(tree.join("release/tool"), &body).unwrap();
97 +
        std::os::unix::fs::symlink("tool", tree.join("release/link")).unwrap();
98 +
        let image = dir.path().join("disk.ext4");
99 +
        crate::workspace::image(&tree, &image).unwrap();
100 +
        assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body);
101 +
        assert!(read(&image, "/release/missing", dir.path()).is_err());
102 +
        assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file"));
103 +
    }
104 +
}
modified crates/flow-core/src/canonical.rs +4 −0
…
68 unchanged lines
69 69
            ),
70 70
        ));
71 71
    }
72 +
    // Artifacts as [name, path] sorted by name; absent when there are none.
73 +
    if !j.outputs.is_empty() {
74 +
        fields.push((7, Value::Array(j.outputs.iter().map(|o| Value::Array(vec![text(&o.name), text(&o.path)])).collect())));
75 +
    }
72 76
    Value::map(fields)
73 77
}
74 78
…
41 unchanged lines
modified crates/flow-core/src/pipeline.rs +94 −1
…
85 unchanged lines
86 86
    /// Ephemeral scratch mounts, sorted by target (spec §3.1 `mount`).
87 87
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 88
    pub mounts: Vec<Mount>,
89 +
    /// Files the job produces, sorted by name (spec §5.2 `output`).
90 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 +
    pub outputs: Vec<Output>,
89 92
}
90 93
91 94
impl Job {
…
44 unchanged lines
136 139
            MountSource::Disk => "ephemeral://disk",
137 140
        }
138 141
    }
142 +
}
143 +
144 +
/// A file a job produces (spec §5.2): read from the job's disks when it
145 +
/// ends, kept in the repository as a blob, and named by its receipt.
146 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147 +
pub struct Output {
148 +
    /// The artifact's name, unique in the job.
149 +
    pub name: String,
150 +
    /// A clean absolute path under `/src`, not on a tmpfs mount.
151 +
    pub path: String,
152 +
}
153 +
154 +
/// Artifacts a job may declare.
155 +
pub const MAX_OUTPUTS: usize = 16;
156 +
157 +
/// Whether `p` is a clean absolute path strictly under `/src`, with nothing
158 +
/// a debugfs command line would have to quote.
159 +
fn output_path_ok(p: &str) -> bool {
160 +
    p.strip_prefix("/src/").is_some_and(|rest| {
161 +
        !rest.is_empty()
162 +
            && rest.split('/').all(|c| !c.is_empty() && c != "." && c != "..")
163 +
            && p.bytes().all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\')
164 +
    })
139 165
}
140 166
141 167
/// Mounts a job may declare.
…
38 unchanged lines
180 206
181 207
/// Nodes of the full spec that M0 recognises and refuses.
182 208
const LATER_TOP: &[&str] = &["publish", "secret"];
183 -
const LATER_JOB: &[&str] = &["output", "secret", "attest"];
209 +
const LATER_JOB: &[&str] = &["secret", "attest"];
184 210
185 211
struct Ctx<'a> {
186 212
    src: &'a str,
…
457 unchanged lines
644 670
    Some(Mount { source: source?, target: target?, size_mib: size? })
645 671
}
646 672
673 +
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
674 +
fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
675 +
    if !node.entries().is_empty() {
676 +
        ctx.err(node.span(), "`output` takes a block of artifacts");
677 +
    }
678 +
    for a in block(ctx, node) {
679 +
        if a.name().value() != "artifact" {
680 +
            ctx.err(a.span(), format!("unknown output `{}`; outputs are `artifact \"name\" {{ path \"/src/…\" }}`", a.name().value()));
681 +
            continue;
682 +
        }
683 +
        let Some(name) = ctx.one_string(a) else { continue };
684 +
        if !ctx.name_ok(a.span(), "artifact", &name) {
685 +
            continue;
686 +
        }
687 +
        let mut path = None;
688 +
        for c in block(ctx, a) {
689 +
            ctx.no_children(c);
690 +
            match c.name().value() {
691 +
                "path" => match ctx.one_string(c) {
692 +
                    Some(p) if output_path_ok(&p) => path = Some(p),
693 +
                    Some(p) => ctx.err(
694 +
                        c.span(),
695 +
                        format!("artifact path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"),
696 +
                    ),
697 +
                    None => {}
698 +
                },
699 +
                "format" => {
700 +
                    if ctx.one_string(c).as_deref() != Some("blob") {
701 +
                        ctx.err(c.span(), "artifact format is \"blob\"");
702 +
                    }
703 +
                }
704 +
                other => ctx.err(c.span(), format!("unknown artifact setting `{other}`")),
705 +
            }
706 +
        }
707 +
        let Some(path) = path else {
708 +
            ctx.err(a.span(), format!("artifact {name:?} needs a path"));
709 +
            continue;
710 +
        };
711 +
        if job.outputs.iter().any(|o| o.name == name) {
712 +
            ctx.err(a.span(), format!("artifact {name:?} is given twice"));
713 +
        } else if job.outputs.len() == MAX_OUTPUTS {
714 +
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts"));
715 +
        } else {
716 +
            job.outputs.push(Output { name, path });
717 +
        }
718 +
    }
719 +
}
720 +
647 721
fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> {
648 722
    let name = ctx.one_string(node)?;
649 723
    let named = ctx.name_ok(node.span(), "job", &name);
…
5 unchanged lines
655 729
        env: BTreeMap::new(),
656 730
        exec: vec![],
657 731
        mounts: vec![],
732 +
        outputs: vec![],
658 733
    };
659 734
    let mut seen = BTreeSet::new();
735 +
    let mut outputs_at = None;
660 736
    for c in block(ctx, node) {
661 737
        let key = c.name().value();
662 738
        if key != "scope" && key != "depends-on" && key != "mount" && !seen.insert(key.to_string()) {
…
45 unchanged lines
708 784
                    }
709 785
                }
710 786
            }
787 +
            "output" => {
788 +
                outputs_at = Some(c.span());
789 +
                parse_output(ctx, c, &mut job);
790 +
            }
711 791
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
712 792
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
713 793
        }
714 794
    }
715 795
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
796 +
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
797 +
    // A tmpfs is gone when the job ends; nothing on it can be kept.
798 +
    for o in &job.outputs {
799 +
        if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) {
800 +
            ctx.err(
801 +
                outputs_at.unwrap_or(node.span()),
802 +
                format!(
803 +
                    "artifact {:?} is on the tmpfs mount {:?}, which is gone when the job ends; use ephemeral://disk",
804 +
                    o.name, m.target
805 +
                ),
806 +
            );
807 +
        }
808 +
    }
716 809
    if job.sandbox.is_empty() && !seen.contains("sandbox") {
717 810
        ctx.err(node.span(), format!("job {name:?} needs a sandbox"));
718 811
    }
…
9 unchanged lines
modified crates/flow-core/src/tests.rs +34 −0
…
323 unchanged lines
324 324
    let bad = problems(&src("    env {\n        BAD-NAME \"x\"\n    }\n"));
325 325
    assert!(bad.iter().any(|(_, m)| m.contains("letters, digits and _")), "{bad:?}");
326 326
}
327 +
328 +
#[test]
329 +
fn artifacts_are_declared_checked_and_hashed() {
330 +
    let src = |out: &str| {
331 +
        doc(&format!(
332 +
            "job \"build\" {{\n    sandbox \"alpine\"\n    mount {{\n        source \"ephemeral://disk\"\n        target \"/src/target\"\n        size \"1GiB\"\n    }}\n    mount {{\n        source \"ephemeral://tmpfs\"\n        target \"/src/tmp\"\n        size \"64MiB\"\n    }}\n    exec \"make\"\n{out}}}\n"
333 +
        ))
334 +
    };
335 +
    let p = Pipeline::parse(&src(
336 +
        "    output {\n        artifact \"tool\" {\n            path \"/src/target/release/tool\"\n            format \"blob\"\n        }\n        artifact \"docs\" {\n            path \"/src/docs/tool.1\"\n        }\n    }\n",
337 +
    ))
338 +
    .unwrap();
339 +
    let names: Vec<(&str, &str)> = p.jobs["build"].outputs.iter().map(|o| (o.name.as_str(), o.path.as_str())).collect();
340 +
    assert_eq!(names, [("docs", "/src/docs/tool.1"), ("tool", "/src/target/release/tool")], "sorted by name");
341 +
    // Artifacts are part of the job's hash; a job without them hashes as before.
342 +
    let plain = Pipeline::parse(&src("")).unwrap();
343 +
    let sb = &p.sandboxes["alpine"];
344 +
    assert_ne!(p.jobs["build"].hash(sb), plain.jobs["build"].hash(sb));
345 +
    assert!(plain.jobs["build"].outputs.is_empty());
346 +
    let json = serde_json::to_string(&plain.jobs["build"]).unwrap();
347 +
    assert!(!json.contains("outputs"), "no outputs field when there are none: {json}");
348 +
349 +
    let bad = |out: &str| problems(&src(out)).into_iter().map(|(_, m)| m).collect::<Vec<_>>().join("\n");
350 +
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/etc/passwd\"\n        }\n    }\n")
351 +
        .contains("must be an absolute path under /src"));
352 +
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/../etc\"\n        }\n    }\n")
353 +
        .contains("must be an absolute path under /src"));
354 +
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/tmp/a\"\n        }\n    }\n").contains("tmpfs"));
355 +
    assert!(bad("    output {\n        artifact \"x\" {\n        }\n    }\n").contains("needs a path"));
356 +
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n        }\n        artifact \"x\" {\n            path \"/src/b\"\n        }\n    }\n")
357 +
        .contains("given twice"));
358 +
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n            format \"tar\"\n        }\n    }\n")
359 +
        .contains("format"));
360 +
}
modified crates/flow/src/main.rs +1 −0
…
3 unchanged lines
4 4
mod cpio;
5 5
mod home;
6 6
mod microvm;
7 +
mod outputs;
7 8
mod runner;
8 9
mod workspace;
9 10
…
288 unchanged lines
modified crates/flow/src/runner.rs +60 −3
…
47 unchanged lines
48 48
        Ok(())
49 49
    }
50 50
51 +
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
52 +
    /// a blob of the job's repository and answers with its ObjectRef.
53 +
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
54 +
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
55 +
        let mut delays = RETRY_DELAYS.iter();
56 +
        loop {
57 +
            let req = self
58 +
                .agent
59 +
                .post(&url)
60 +
                .timeout(Duration::from_secs(600))
61 +
                .set("Authorization", &format!("Bearer {}", self.token))
62 +
                .set("Content-Type", "application/octet-stream");
63 +
            match req.send_bytes(bytes) {
64 +
                Ok(r) => {
65 +
                    let v: serde_json::Value = r.into_json()?;
66 +
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
67 +
                }
68 +
                Err(ureq::Error::Status(code, r)) => {
69 +
                    let text = r.into_string().unwrap_or_default();
70 +
                    match delays.next() {
71 +
                        Some(d) if code >= 500 => std::thread::sleep(*d),
72 +
                        _ => bail!("{url}: HTTP {code}: {text}"),
73 +
                    }
74 +
                }
75 +
                Err(e) => bail!("{url}: {e}"),
76 +
            }
77 +
        }
78 +
    }
79 +
51 80
    pub fn finish(&self, job: i64, f: &Finish) -> Result<()> {
52 81
        self.post(&format!("/flow/jobs/{job}/finish"), f)?;
53 82
        Ok(())
…
207 unchanged lines
261 290
            Duration::from_secs(claim.timeout_s),
262 291
            &mut |r| sink.push(r),
263 292
        )?;
264 -
        let receipt = outcome.exit_code.map(|code| Receipt {
293 +
        // A passing job's artifacts, read from its disks and uploaded; a
294 +
        // declared artifact that cannot be kept fails the job.
295 +
        let mut outputs = vec![];
296 +
        let mut output_error = None;
297 +
        if outcome.exit_code == Some(0) {
298 +
            for o in &claim.job.outputs {
299 +
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
300 +
                let kept = crate::outputs::read(&image, &inner, &scratch).and_then(|bytes| {
301 +
                    let blob = Repo::blob_ref(&bytes);
302 +
                    let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
303 +
                    if named != blob.to_hex() {
304 +
                        bail!("the hub kept it as {named}, not {}", blob.to_hex());
305 +
                    }
306 +
                    note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12]));
307 +
                    Ok(blob.0)
308 +
                });
309 +
                match kept {
310 +
                    Ok(b) => outputs.push(b),
311 +
                    Err(e) => {
312 +
                        output_error = Some(format!("artifact {}: {e:#}", o.name));
313 +
                        break;
314 +
                    }
315 +
                }
316 +
            }
317 +
        }
318 +
        if let Some(e) = &output_error {
319 +
            note(&mut sink, e.clone());
320 +
        }
321 +
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
265 322
            job_name: claim.job.name.clone(),
266 323
            pipeline_hash: hex32(&claim.pipeline_hash),
267 324
            scope_tree_hash: scope_hash,
268 325
            inputs: vec![],
269 326
            environment: outcome.environment.hash(),
270 -
            outputs: vec![],
327 +
            outputs,
271 328
            exit_code: code as u32,
272 329
            duration_ms: outcome.duration_ms,
273 330
        });
…
6 unchanged lines
280 337
            duration_ms: started.elapsed().as_millis() as u64,
281 338
            receipt,
282 339
            signed_receipt,
283 -
            error: outcome.timed_out.then(|| "stopped at the time limit".to_string()),
340 +
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
284 341
        })
285 342
    })();
286 343
    let _ = std::fs::remove_dir_all(&scratch);
…
93 unchanged lines
modified docs/m0.md +14 −4
…
52 unchanged lines
53 53
- Names are 1 to 64 characters from `[A-Za-z0-9._-]`.
54 54
- `exec` becomes `argv` unchanged: metacharacters are bytes (spec §10.1).
55 55
  A job that wants a shell names one, as `sh` above; nothing adds one.
56 -
- `output`, `publish` and `secret` are recognised and refused with
57 -
  "not in M0", so a pipeline written for the full spec fails clearly.
56 +
- `publish` and `secret` are recognised and refused with "not in M0", so
57 +
  a pipeline written for the full spec fails clearly.
58 +
- `output { artifact "oxy" { path "/src/target/release/oxy" } }` declares
59 +
  up to 16 **artifacts** (spec §5.2). The path is clean, absolute, under
60 +
  `/src`, and not on a tmpfs mount, which is gone when the job ends. After
61 +
  a job passes, the runner reads each artifact from the job's disks with
62 +
  `debugfs`: the workspace, or the `ephemeral://disk` mount holding it. An
63 +
  artifact is at most 512 MiB. The runner uploads each one with
64 +
  `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes). The hub keeps
65 +
  it as a blob of the repository, and the receipt's `outputs` name those
66 +
  blobs in artifact order. A declared artifact that cannot be kept fails
67 +
  the job, and the job gets no receipt.
58 68
- `mount` is accepted for **ephemeral scratch** only (up to 4 per job):
59 69
  `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
60 70
  `ephemeral://tmpfs` is memory in the guest and counts against its
…
132 unchanged lines
193 203
## 7. Not in M0
194 204
195 205
WASM tier; FastCDC-backed rootfs and `casfs` mounts (M0 copies the tree
196 -
into an image); mounts other than ephemeral scratch, `output` and artifacts; receipt reuse; provenance
197 -
attestations and composite signatures; policy gates; secrets; proposal
206 +
into an image); mounts other than ephemeral scratch; artifacts as inputs of later jobs; receipt reuse; provenance
207 +
attestations; policy gates; secrets; proposal
198 208
runs; scope-based skipping; re-run and cancel from the hub.