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.