OXYGENHUB

Runs by event: on decides the jobs; tag pushes run tag jobs c889911bec12

A push of a new tag starts a run (trigger tag:NAME) of the jobs whose on
names it; push and manual runs keep the jobs their event starts, with
scope still narrowing pushes; no job, no run. Reruns record the event
they repeat (rerun:N:EVENT, shown as rerun:N). A bookmark pushed
unchanged starts nothing. Pins oxygen (tags as updates) and oxygenflow
(on). Tests.

refs oxygenflow#24
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 21:59 UTC
Signature
✔ signed by Andy McCall
Hash
c889911bec12fde5e9a56c3ecf5b96d48d3ef28223841ef3b34222663276a1ce
Depends on
0a56c5313bc0 1edac4b68c6d 380996db84e5 42fa95540468 4f6925337848 5dead1f4f26d 749d9a5fcd9f 9008bf6c2b8c 90326c9601f9 b5668108487f d23464c0ac60 d7ce41acf22c
Depended on by
5f3fa151688e 367f34b8e590 a9f1c0bf403a 2abea30809dd 417184c73f4e 44a3d2ebaddd 0650d94ede82 2a83f5303458 745d225de344 348f8c4baccf
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
20 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **What starts a job: `on` (oxygenflow#24).** A pipeline's or a job's `on { push "main" "feature/*"; tag "v*"; manual }` decides which events run it: pushing a tag now runs the jobs that name it, on the tagged state; a run has only the jobs its event starts (and what they need), and an event that starts none starts no run. A rerun repeats its run's event. A bookmark sent again unchanged starts no run.
8 +
7 9
## 0.3.1 (2026-10-02)
8 10
9 11
- **`oh` for RISC-V and from apt.** `oh` 0.3.1 comes for 64-bit RISC-V Linux too, its .deb adds the apt repository on its first install, and every file is built by OxygenFlow (attested on the release's trust page).
…
55 unchanged lines
modified crates/hub/src/api/mod.rs +1 −1
…
1695 unchanged lines
1696 1696
        state: r.state.clone(),
1697 1697
        heads: r.head_list().into_iter().map(String::from).collect(),
1698 1698
        status: r.status.clone(),
1699 -
        trigger: r.trigger.clone(),
1699 +
        trigger: r.trigger_label(),
1700 1700
        actor: r.actor.clone(),
1701 1701
        pipeline_hash: r.pipeline_hash.clone(),
1702 1702
        error: r.error.clone(),
…
303 unchanged lines
modified crates/hub/src/db/flow.rs +17 −1
…
28 unchanged lines
29 29
    pub fn started_by(&self) -> String {
30 30
        match self.trigger.as_str() {
31 31
            "manual" => format!("started by {}", self.actor),
32 -
            t if t.starts_with("rerun:") => format!("re-run of #{} by {}", &t[6..], self.actor),
32 +
            "schedule" => "started on schedule".to_string(),
33 +
            t if t.starts_with("tag:") => format!("tag {} pushed by {}", &t[4..], self.actor),
34 +
            t if t.starts_with("rerun:") => format!("re-run of #{} by {}", self.rerun_of().unwrap_or(""), self.actor),
33 35
            _ => format!("pushed by {}", self.actor),
34 36
        }
35 37
    }
36 38
39 +
    /// The run a rerun repeats: `N` of `rerun:N` or `rerun:N:EVENT`.
40 +
    pub fn rerun_of(&self) -> Option<&str> {
41 +
        let rest = self.trigger.strip_prefix("rerun:")?;
42 +
        Some(rest.split(':').next().unwrap_or(rest))
43 +
    }
44 +
45 +
    /// The trigger as people read it: `rerun:N`, without the event it repeats.
46 +
    pub fn trigger_label(&self) -> String {
47 +
        match self.rerun_of() {
48 +
            Some(n) => format!("rerun:{n}"),
49 +
            None => self.trigger.clone(),
50 +
        }
51 +
    }
52 +
37 53
    pub fn head_list(&self) -> Vec<&str> {
38 54
        self.heads.split('+').filter(|h| !h.is_empty()).collect()
39 55
    }
…
561 unchanged lines
modified crates/hub/src/flow.rs +52 −8
…
31 unchanged lines
32 32
/// A bookmark moved by a push: start a run when the repository is allowed
33 33
/// and the new state has a pipeline.
34 34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
35 -
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
35 +
    if heads.is_empty() || !enabled(app, row) {
36 36
        return;
37 37
    }
38 -
    let touched = touched_since_last_run(app, row, bookmark, heads).await;
39 -
    if let Err(e) = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await {
38 +
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
39 +
    let started = match bookmark.strip_prefix("tags/") {
40 +
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
41 +
        None => {
42 +
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
43 +
            start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await
44 +
        }
45 +
    };
46 +
    if let Err(e) = started {
40 47
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
41 48
    }
42 49
}
…
54 unchanged lines
97 104
    if heads.is_empty() {
98 105
        return Err("the run names no changes".into());
99 106
    }
100 -
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &format!("rerun:{}", run.number))
107 +
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
108 +
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
109 +
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
101 110
        .await?
102 111
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
112 +
}
113 +
114 +
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
115 +
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
116 +
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
117 +
    use flow_core::pipeline::Event;
118 +
    let t = match trigger.strip_prefix("rerun:") {
119 +
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
120 +
        None => trigger,
121 +
    };
122 +
    if let Some(tag) = t.strip_prefix("tag:") {
123 +
        Event::Tag(tag)
124 +
    } else if t == "push" {
125 +
        Event::Push(bookmark)
126 +
    } else if t == "schedule" {
127 +
        Event::Schedule
128 +
    } else {
129 +
        Event::Manual
130 +
    }
103 131
}
104 132
133 +
/// The event to repeat when rerunning `run`.
134 +
fn original_event(run: &fdb::Run) -> String {
135 +
    let t = run.trigger.as_str();
136 +
    match t.strip_prefix("rerun:") {
137 +
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
138 +
        None => t.to_string(),
139 +
    }
140 +
}
141 +
105 142
/// The paths the changes a push brought touch: the changes in `heads`'
106 143
/// state that the bookmark's previous run did not have, and the files their
107 144
/// edits and tree operations name, as the new state places them. `None`
…
120 unchanged lines
228 265
            let depths = p.depths();
229 266
            // Jobs out of the push's scope stay out, unless a kept job
230 267
            // needs them.
231 -
            let mut keep: std::collections::HashSet<String> =
232 -
                p.order().into_iter().filter(|j| in_scope(&j.scope, touched)).map(|j| j.name.clone()).collect();
268 +
            // The jobs this run's event starts (`on`), out of those its
269 +
            // push touched (`scope`); then what they depend on.
270 +
            let event = event_of(trigger, bookmark);
271 +
            let mut keep: std::collections::HashSet<String> = p
272 +
                .order()
273 +
                .into_iter()
274 +
                .filter(|j| p.on_of(j).matches(event) && in_scope(&j.scope, touched))
275 +
                .map(|j| j.name.clone())
276 +
                .collect();
233 277
            loop {
234 278
                let needed: Vec<String> = p
235 279
                    .order()
…
23 unchanged lines
259 303
                    }
260 304
                })
261 305
                .collect();
262 -
            if jobs.is_empty() && touched.is_some() {
263 -
                // Nothing this push touched is any job's business.
306 +
            if jobs.is_empty() {
307 +
                // Nothing this event starts (or this push touched).
264 308
                return Ok(None);
265 309
            }
266 310
            let hash = hex::encode(p.hash());
…
625 unchanged lines
modified crates/hub/src/protocol.rs +2 −1
…
217 unchanged lines
218 218
        let moved: Vec<(String, String, Vec<oxy_core::hash::ChangeHash>)> = report
219 219
            .updated
220 220
            .iter()
221 -
            .filter(|u| report.results.iter().any(|r| r.ok && r.name == u.name))
221 +
            // A bookmark sent again unchanged starts nothing.
222 +
            .filter(|u| u.old_state != u.new_state && report.results.iter().any(|r| r.ok && r.name == u.name))
222 223
            .map(|u| (u.name.clone(), u.new_state.to_hex(), u.heads.clone()))
223 224
            .collect();
224 225
        let app_hint = self.app_hint.clone();
…
56 unchanged lines
modified crates/hub/tests/flow.rs +70 −1
…
646 unchanged lines
647 647
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/1/rerun", Some("site"), None).await;
648 648
    assert_eq!(st, StatusCode::CREATED, "{body}");
649 649
    let third = db::flow::run(&h.app.db, row.id, 4).await.unwrap().unwrap();
650 -
    assert_eq!((third.trigger.as_str(), third.status.as_str(), third.state.as_str()), ("rerun:1", "queued", first[0].state.as_str()));
650 +
    assert_eq!((third.trigger_label().as_str(), third.status.as_str(), third.state.as_str()), ("rerun:1", "queued", first[0].state.as_str()));
651 +
    assert_eq!(third.trigger, "rerun:1:push", "a rerun keeps the event it repeats");
651 652
    let checks = db::hooks::checks_for_state(&h.app.db, row.id, &third.state).await.unwrap();
652 653
    assert!(
653 654
        checks.iter().all(|c| c.status == "queued" && c.url.ends_with("/flow/4")),
…
872 unchanged lines
1526 1527
    assert_eq!(status(ids["slow"]).await, "running", "within its 90");
1527 1528
    assert_eq!(status(ids["huge"]).await, "running", "within its 120");
1528 1529
}
1530 +
1531 +
/// oxygenflow#24: a job's `on` decides which events start it: a pushed
1532 +
/// tag runs only the jobs that name it; a push to a bookmark the pipeline
1533 +
/// does not name starts no run; a rerun repeats its run's event.
1534 +
#[tokio::test(flavor = "multi_thread")]
1535 +
async fn on_decides_which_events_start_a_job() {
1536 +
    let h = hub().await;
1537 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
1538 +
    create(&h.app, "andy", "events").await;
1539 +
    let url = format!("{}/andy/events", h.base);
1540 +
    let work = tempfile::tempdir().unwrap();
1541 +
    let c = client(&url, work.path());
1542 +
    let text = format!(
1543 +
        "on {{\n    push \"main\"\n    manual\n}}\n{}job \"dist\" {{\n    sandbox \"alpine\"\n    on {{\n        tag \"v*\"\n    }}\n    exec \"true\"\n}}\n",
1544 +
        pipeline("")
1545 +
    );
1546 +
    // `on` must come after schema-version; pipeline() starts with it.
1547 +
    let text = text.replacen(
1548 +
        "on {\n    push \"main\"\n    manual\n}\nschema-version 1\n",
1549 +
        "schema-version 1\non {\n    push \"main\"\n    manual\n}\n",
1550 +
        1,
1551 +
    );
1552 +
    write(work.path(), ".flow/pipeline.kdl", &text);
1553 +
    write(work.path(), "test.sh", "echo ok\n");
1554 +
    c.commit("Start", &[]).unwrap();
1555 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1556 +
    let jobs_of_latest = |app: Shared, n: usize| async move {
1557 +
        let mut runs = Vec::new();
1558 +
        for _ in 0..100 {
1559 +
            runs = runs_of(&app, "andy", "events").await;
1560 +
            if runs.len() >= n {
1561 +
                break;
1562 +
            }
1563 +
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1564 +
        }
1565 +
        assert_eq!(runs.len(), n, "runs");
1566 +
        let mut j: Vec<String> = db::flow::jobs(&app.db, runs[0].id).await.unwrap().into_iter().map(|j| j.name).collect();
1567 +
        j.sort();
1568 +
        (runs[0].clone(), j)
1569 +
    };
1570 +
    let (_, j) = jobs_of_latest(h.app.clone(), 1).await;
1571 +
    assert_eq!(j, ["build", "lint", "test"], "a push to main: no dist");
1572 +
    // A tag: only dist.
1573 +
    let heads = c.bookmark_heads().unwrap();
1574 +
    c.tag_create("v1.0.0", &heads, "first").unwrap();
1575 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1576 +
    let (run, j) = jobs_of_latest(h.app.clone(), 2).await;
1577 +
    assert_eq!(j, ["dist"], "a tag runs only what names it");
1578 +
    assert_eq!(run.trigger, "tag:v1.0.0");
1579 +
    // A rerun repeats the tag's event.
1580 +
    let row = db::repos::get(&h.app.db, "andy", "events").await.unwrap().unwrap();
1581 +
    db::flow::finish(
1582 +
        &h.app.db,
1583 +
        db::flow::jobs(&h.app.db, run.id).await.unwrap()[0].id,
1584 +
        db::flow::Outcome { status: "passed", exit_code: Some(0), error: "", receipt: "", receipt_hash: "" },
1585 +
    )
1586 +
    .await
1587 +
    .unwrap();
1588 +
    let run = db::flow::run_by_id(&h.app.db, run.id).await.unwrap().unwrap();
1589 +
    oxygenhub::flow::rerun(&h.app, &row, &run, "andy").await.unwrap();
1590 +
    let (_, j) = jobs_of_latest(h.app.clone(), 3).await;
1591 +
    assert_eq!(j, ["dist"], "the rerun is the tag's run again");
1592 +
    // A bookmark the pipeline does not name starts nothing.
1593 +
    c.bookmark_create("develop", None).unwrap();
1594 +
    assert!(c.push_bookmark("origin", "develop", false).unwrap().iter().all(|r| r.ok));
1595 +
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1596 +
    assert_eq!(runs_of(&h.app, "andy", "events").await.len(), 3, "no run for develop");
1597 +
}
replaced deps/oxygenflow +0 −0

No line changes.