OXYGENHUB

Push without starting a run: [skip flow] (closes #284) 367f34b8e590

[skip flow], [skip ci] or [ci skip] in the newest pushed change's message
starts no jobs: a run is recorded as skipped (status skipped, flow_runs
rebuilt for it), naming the change and who pushed it, and is never the next
run's baseline, so what it brought is still tested. Tags always run.
Settings > Runs chooses where it is allowed: any bookmark, any but the
default, or never (repositories.flow_skip, audited).
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2ff542346a91
When
· 2026-10-20 19:38 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
367f34b8e590b83920b391fbe78d201500c6b051145d52f8f6f2d9c060f8fa67
Depends on
0ee5c3fb32eb 107eaba52c9d 19bb31ff1f5c 1edac4b68c6d 3b1bc27fe805 42fa95540468 4f6925337848 5d90e0e045e2 5dead1f4f26d 745d225de344 8bf3010760d7 93be9ce4653e a7e6e8e6094c a9f1c0bf403a b5668108487f bf3d07541368 c7067c7bda56 c889911bec12 d23464c0ac60
Depended on by
42cd46c9b09e 5588bb3631d2 3b3a51afde59 8597fb5ca16c
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
24 atoms, 2 tree operations, 14 files

Browse the tree at this change

added crates/hub/migrations-pg/0088_flow_skip.sql +10 −0
1 +
-- Pushing without a run (oxygenhub#284): `[skip flow]`, `[skip ci]` or
2 +
-- `[ci skip]` in the newest pushed change's message records a skipped run
3 +
-- instead of starting jobs. Where a maintainer allows it: `any` bookmark
4 +
-- (the default), `others` than the default bookmark, or `never`.
5 +
ALTER TABLE repositories ADD COLUMN flow_skip TEXT NOT NULL DEFAULT 'any';
6 +
7 +
-- A skipped run is recorded with status `skipped`.
8 +
ALTER TABLE flow_runs DROP CONSTRAINT flow_runs_status_check;
9 +
ALTER TABLE flow_runs ADD CONSTRAINT flow_runs_status_check
10 +
    CHECK (status IN ('queued', 'running', 'passed', 'failed', 'error', 'cancelled', 'skipped'));
added crates/hub/migrations/0088_flow_skip.sql +34 −0
1 +
-- Pushing without a run (oxygenhub#284): `[skip flow]`, `[skip ci]` or
2 +
-- `[ci skip]` in the newest pushed change's message records a skipped run
3 +
-- instead of starting jobs. Where a maintainer allows it: `any` bookmark
4 +
-- (the default), `others` than the default bookmark, or `never`.
5 +
ALTER TABLE repositories ADD COLUMN flow_skip TEXT NOT NULL DEFAULT 'any';
6 +
7 +
-- A skipped run is recorded with status `skipped`. SQLite cannot change a
8 +
-- CHECK constraint in place, so flow_runs is rebuilt as in 0033 (with
9 +
-- foreign keys off during migrations, its jobs and reports stay).
10 +
CREATE TABLE flow_runs_new (
11 +
    id            INTEGER PRIMARY KEY,
12 +
    repo_id       INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
13 +
    bookmark      TEXT NOT NULL,
14 +
    -- The bookmark's state hash (checks are keyed by it) and its heads, joined with '+'.
15 +
    state         TEXT NOT NULL,
16 +
    heads         TEXT NOT NULL,
17 +
    pipeline_hash TEXT NOT NULL DEFAULT '',
18 +
    status        TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'error', 'cancelled', 'skipped')),
19 +
    -- Pipeline problems, one per line, for a run in error; why, for a skipped one.
20 +
    error         TEXT NOT NULL DEFAULT '',
21 +
    actor         TEXT NOT NULL DEFAULT '',
22 +
    created_ms    INTEGER NOT NULL,
23 +
    finished_ms   INTEGER,
24 +
    number        INTEGER NOT NULL DEFAULT 0,
25 +
    trigger       TEXT NOT NULL DEFAULT 'push',
26 +
    priority      TEXT NOT NULL DEFAULT '',
27 +
    priority_note TEXT NOT NULL DEFAULT ''
28 +
);
29 +
INSERT INTO flow_runs_new (id, repo_id, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms, number, trigger, priority, priority_note)
30 +
    SELECT id, repo_id, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms, number, trigger, priority, priority_note FROM flow_runs;
31 +
DROP TABLE flow_runs;
32 +
ALTER TABLE flow_runs_new RENAME TO flow_runs;
33 +
CREATE INDEX flow_runs_by_repo ON flow_runs (repo_id, id DESC);
34 +
CREATE UNIQUE INDEX flow_runs_number ON flow_runs (repo_id, number);
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Push without starting a run (#284):** `[skip flow]`, `[skip ci]` or `[ci skip]` in the newest pushed change's message starts no jobs; the run list shows a skipped run naming the change and who pushed it, and the next run still tests what it brought. Tags always run. Maintainers choose where it is allowed under Settings › Runs: any bookmark (the default), any but the default bookmark, or never.
7 8
- **A drop's page is centred and its header clearer (#283):** the page ran the full width with everything against the left edge; it is now centred like other pages, with the title, description and details on the left, EDIT and DELETE together on the right (DELETE only for whoever may delete it), the clone line with a copy button under them, and a Markdown file filling its box.
8 9
- **A started job no longer says it is waiting (#282):** a run page opened while a job was queued kept "Waiting for a runner and for build." under it after it started, as the live update changed its status and streamed its log; the note now goes once the job leaves the queue.
9 10
- **Linking the identity that delegates to the hub (#281):** the notice on pages that commit for you (New drop, web edits) read as if the hub's key file was what to link under Settings › Keys, which then asked for a signature nobody but the hub can make. It now says to export your own identity again after `oxy identity delegate add` and upload that; uploading the hub's own key is refused saying so, uploading an already linked key says to export again after delegating, and a challenge signed by another key than the one uploaded names both keys. The New drop form runs the page's width, and CREATE DROP and CANCEL are the same height in one row.
…
266 unchanged lines
modified crates/hub/src/db/flow.rs +27 −1
…
250 unchanged lines
251 251
    pub error: Option<&'a str>,
252 252
}
253 253
254 +
/// A push that asked for no run (#284): recorded, finished, as skipped,
255 +
/// with `why` (which change asked, and who pushed it) as its note.
256 +
pub async fn create_skipped_run(pool: &Pool, r: NewRun<'_>, why: &str) -> sqlx::Result<i64> {
257 +
    let mut tx = super::begin_write(pool).await?;
258 +
    let now = now_ms();
259 +
    let id: i64 = sqlx::query_scalar(
260 +
        "INSERT INTO flow_runs (repo_id, number, bookmark, state, heads, pipeline_hash, status, error, actor, trigger, created_ms, finished_ms) \
261 +
         VALUES ($1, (SELECT COALESCE(MAX(number), 0) + 1 FROM flow_runs WHERE repo_id = $2), $3, $4, $5, $6, 'skipped', $7, $8, $9, $10, $11) RETURNING id",
262 +
    )
263 +
    .bind(r.repo_id)
264 +
    .bind(r.repo_id)
265 +
    .bind(r.bookmark)
266 +
    .bind(r.state)
267 +
    .bind(r.heads)
268 +
    .bind(r.pipeline_hash)
269 +
    .bind(why)
270 +
    .bind(r.actor)
271 +
    .bind(r.trigger)
272 +
    .bind(now)
273 +
    .bind(now)
274 +
    .fetch_one(&mut *tx)
275 +
    .await?;
276 +
    tx.commit().await?;
277 +
    Ok(id)
278 +
}
279 +
254 280
pub async fn create_run(pool: &Pool, r: NewRun<'_>, jobs: &[NewJob]) -> sqlx::Result<i64> {
255 281
    let mut tx = super::begin_write(pool).await?;
256 282
    let now = now_ms();
…
167 unchanged lines
424 450
/// that what a cancelled run would have checked is checked by the next.
425 451
pub async fn latest_on(pool: &Pool, repo_id: i64, bookmark: &str) -> sqlx::Result<Option<Run>> {
426 452
    sqlx::query_as(
427 -
        "SELECT * FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND heads != '' AND status != 'cancelled'
453 +
        "SELECT * FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND heads != '' AND status NOT IN ('cancelled', 'skipped')
428 454
         AND NOT (trigger = 'push' AND status IN ('queued', 'running')) ORDER BY id DESC LIMIT 1",
429 455
    )
430 456
    .bind(repo_id)
…
863 unchanged lines
modified crates/hub/src/db/repos.rs +9 −0
…
54 unchanged lines
55 55
    /// only queued ones are cancelled. Off by default.
56 56
    #[sqlx(default)]
57 57
    pub flow_keep_running: bool,
58 +
    /// Where a push may skip its run with `[skip flow]` (#284, migration
59 +
    /// 0088): `any` bookmark, `others` than the default one, or `never`.
60 +
    #[sqlx(default)]
61 +
    pub flow_skip: String,
58 62
    /// Pushes that add possible secrets are refused (#268, migration
59 63
    /// 0085). On by default.
60 64
    #[sqlx(default)]
…
378 unchanged lines
439 443
440 444
/// Whether a new run leaves the older runs it supersedes that are already
441 445
/// running to finish (#267), cancelling only the queued ones.
446 +
pub async fn set_flow_skip(pool: &Pool, id: i64, skip: &str) -> sqlx::Result<()> {
447 +
    sqlx::query("UPDATE repositories SET flow_skip = $1 WHERE id = $2").bind(skip).bind(id).execute(pool).await?;
448 +
    Ok(())
449 +
}
450 +
442 451
pub async fn set_flow_keep_running(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
443 452
    sqlx::query("UPDATE repositories SET flow_keep_running = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?;
444 453
    Ok(())
…
41 unchanged lines
modified crates/hub/src/flow.rs +76 −0
…
43 unchanged lines
44 44
    let started = match bookmark.strip_prefix("tags/") {
45 45
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
46 46
        None => {
47 +
            // `[skip flow]` in the newest change it brings (#284): recorded,
48 +
            // no jobs.
49 +
            if let Some((change, marker)) = skip_asked(app, row, bookmark, heads).await {
50 +
                skip_run(app, row, bookmark, state, heads, actor, &change, marker).await;
51 +
                return;
52 +
            }
47 53
            // It supersedes the push runs still going on the bookmark (#267).
48 54
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
49 55
            start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await
…
1 unchanged lines
51 57
    };
52 58
    if let Err(e) = started {
53 59
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
60 +
    }
61 +
}
62 +
63 +
/// The markers that ask a push not to start a run (#284), as the forges
64 +
/// spell them; matched in any case.
65 +
pub const SKIP_MARKERS: [&str; 3] = ["[skip flow]", "[skip ci]", "[ci skip]"];
66 +
67 +
/// The marker in `message` that asks for no run, if any.
68 +
pub fn skip_marker(message: &str) -> Option<&'static str> {
69 +
    let m = message.to_lowercase();
70 +
    SKIP_MARKERS.into_iter().find(|k| m.contains(k))
71 +
}
72 +
73 +
/// Whether the repository lets a push to `bookmark` skip its run: `any`
74 +
/// bookmark (the default), `others` than the default one, or `never`.
75 +
pub fn skip_allowed(row: &db::repos::RepoRow, bookmark: &str) -> bool {
76 +
    match row.flow_skip.as_str() {
77 +
        "never" => false,
78 +
        "others" => bookmark != row.default_bookmark,
79 +
        _ => true,
80 +
    }
81 +
}
82 +
83 +
/// The newest change of `heads` (by its clock) when its message asks for
84 +
/// no run and the repository allows that here: its hash and the marker.
85 +
async fn skip_asked(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<(ChangeHash, &'static str)> {
86 +
    if !skip_allowed(row, bookmark) {
87 +
        return None;
88 +
    }
89 +
    let repo = RepoName::new(&row.owner, &row.name)?;
90 +
    let h = heads.to_vec();
91 +
    let newest = app
92 +
        .repos
93 +
        .read(&repo, move |r, _| {
94 +
            let mut best: Option<(oxy_core::objects::Hlc, ChangeHash, String)> = None;
95 +
            for x in &h {
96 +
                let (c, _) = r.store().get_change_lazy(x)?;
97 +
                if best.as_ref().is_none_or(|(t, _, _)| c.hlc > *t) {
98 +
                    best = Some((c.hlc, *x, c.message.clone()));
99 +
                }
100 +
            }
101 +
            Ok(best)
102 +
        })
103 +
        .await
104 +
        .ok()??;
105 +
    skip_marker(&newest.2).map(|m| (newest.1, m))
106 +
}
107 +
108 +
/// Record a push's run as skipped (#284): finished, with no jobs, saying
109 +
/// which change asked and who pushed it. The next run's scope still covers
110 +
/// what this push brought, as a skipped run is never its baseline.
111 +
#[allow(clippy::too_many_arguments)]
112 +
async fn skip_run(
113 +
    app: &Shared,
114 +
    row: &db::repos::RepoRow,
115 +
    bookmark: &str,
116 +
    state: &str,
117 +
    heads: &[ChangeHash],
118 +
    actor: &str,
119 +
    change: &ChangeHash,
120 +
    marker: &str,
121 +
) {
122 +
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
123 +
    hex.sort();
124 +
    let heads_s = hex.join("+");
125 +
    let why = format!("{marker} in change {}, pushed by {actor}", change.short(12));
126 +
    let base =
127 +
        db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger: "push", error: None };
128 +
    if let Err(e) = db::flow::create_skipped_run(&app.db, base, &why).await {
129 +
        tracing::warn!("flow: recording a skipped run of {}/{}: {e}", row.owner, row.name);
54 130
    }
55 131
}
56 132
…
2540 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
341 unchanged lines
342 342
        .route("/{owner}/{name}/settings/secret-resolve", post(repo_settings::secret_resolve))
343 343
        .route("/{owner}/{name}/settings/secret-scan", post(repo_settings::secret_scan))
344 344
        .route("/{owner}/{name}/settings/flow-supersede", post(repo_settings::flow_supersede))
345 +
        .route("/{owner}/{name}/settings/flow-skip", post(repo_settings::flow_skip))
345 346
        .route("/{owner}/{name}/settings/diffusion", get(repo_settings::page).post(repo_settings::diffusion))
346 347
        .route("/{owner}/{name}/settings/backup", get(repo_settings::page).post(repo_settings::backup))
347 348
        .route("/{owner}/{name}/settings/storage", get(repo_settings::page).post(repo_settings::storage))
…
550 unchanged lines
modified crates/hub/src/web/repo_settings.rs +31 −0
…
1331 unchanged lines
1332 1332
}
1333 1333
1334 1334
#[derive(Deserialize)]
1335 +
pub struct SkipForm {
1336 +
    #[serde(rename = "_csrf")]
1337 +
    csrf_token: String,
1338 +
    /// `any`, `others` or `never`.
1339 +
    skip: String,
1340 +
}
1341 +
1342 +
/// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push
1343 +
/// may skip its run with `[skip flow]` in its message; for maintainers,
1344 +
/// audited.
1345 +
pub async fn flow_skip(
1346 +
    State(app): State<Shared>,
1347 +
    viewer: Viewer,
1348 +
    Path((owner, name)): Path<(String, String)>,
1349 +
    Form(f): Form<SkipForm>,
1350 +
) -> Result<Response, Error> {
1351 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1352 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1353 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1354 +
    let notice = match f.skip.as_str() {
1355 +
        "any" => "A push may now skip its run on any bookmark.".to_string(),
1356 +
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
1357 +
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
1358 +
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1359 +
    };
1360 +
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1361 +
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
1362 +
    back(&row, Section::Runs, Some(notice), None)
1363 +
}
1364 +
1365 +
#[derive(Deserialize)]
1335 1366
pub struct PriorityForm {
1336 1367
    #[serde(rename = "_csrf")]
1337 1368
    csrf_token: String,
…
245 unchanged lines
modified crates/hub/tests/accounts.rs +8 −1
…
465 unchanged lines
466 466
        }
467 467
    }
468 468
    let runs = get(app, "/andy/demo/settings/runs", Some(&andy)).await.body;
469 -
    assert!(runs.contains("id=\"flow-priority\"") && runs.contains("id=\"flow-supersede\"") && !runs.contains("id=\"general\""));
469 +
    assert!(
470 +
        runs.contains("id=\"flow-priority\"")
471 +
            && runs.contains("id=\"flow-supersede\"")
472 +
            && runs.contains("id=\"flow-skip\"")
473 +
            && !runs.contains("id=\"general\"")
474 +
    );
470 475
    // Storage is only for a repository kept in a bucket.
471 476
    assert_eq!(get(app, "/andy/demo/settings/storage", Some(&andy)).await.status, StatusCode::NOT_FOUND);
472 477
    assert!(!general.body.contains("/andy/demo/settings/storage\""));
…
33 unchanged lines
506 511
        ("/flow", "flow=sometimes", "flow", "Pick on, own runners, off or the site default."),
507 512
        ("/flow-priority", "priority=low", "runs", "Its jobs are low priority."),
508 513
        ("/flow-supersede", "running=on", "runs", "cancels the older runs it supersedes, queued or running"),
514 +
        ("/flow-skip", "skip=others", "runs", "skip its run on any bookmark but main"),
515 +
        ("/flow-skip", "skip=sometimes", "runs", "Choose where a push may skip its run."),
509 516
        ("/agent-gates", "agents=on", "gates", "may now approve or reject"),
510 517
        ("/variables", "action=set&name=RUST_LOG&value=debug", "variables", "RUST_LOG set."),
511 518
        ("/coverage", "min=80&max_drop=", "coverage", "Coverage rules saved"),
…
1234 unchanged lines
modified crates/hub/tests/flow.rs +80 −0
…
1820 unchanged lines
1821 1821
    assert_eq!(names(h.app.clone(), 3).await, ["build", "dist", "lint", "test"], "a version bump runs dist");
1822 1822
}
1823 1823
1824 +
/// oxygenhub#284: a push whose newest change says `[skip ci]` records a
1825 +
/// skipped run with no jobs; the next run's scope still covers what it
1826 +
/// brought; and a repository can refuse skips on its default bookmark.
1827 +
#[tokio::test(flavor = "multi_thread")]
1828 +
async fn a_push_can_skip_its_run() {
1829 +
    let h = hub().await;
1830 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
1831 +
    create(&h.app, "andy", "skip").await;
1832 +
    let url = format!("{}/andy/skip", h.base);
1833 +
    let work = tempfile::tempdir().unwrap();
1834 +
    let c = client(&url, work.path());
1835 +
    let extra = "job \"dist\" {\n    sandbox \"alpine\"\n    scope \"Cargo.toml\"\n    exec \"true\"\n}\n";
1836 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(extra));
1837 +
    write(work.path(), "test.sh", "echo ok\n");
1838 +
    write(work.path(), "Cargo.toml", "version = \"0.1.0\"\n");
1839 +
    c.commit("Start", &[]).unwrap();
1840 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1841 +
    let newest = |app: Shared, n: usize| async move {
1842 +
        for _ in 0..100 {
1843 +
            let runs = runs_of(&app, "andy", "skip").await;
1844 +
            if runs.len() >= n {
1845 +
                assert_eq!(runs.len(), n, "runs");
1846 +
                let r = runs[0].clone();
1847 +
                let mut jobs: Vec<String> = db::flow::jobs(&app.db, r.id).await.unwrap().into_iter().map(|j| j.name).collect();
1848 +
                jobs.sort();
1849 +
                if r.status != "skipped" {
1850 +
                    sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = $1").bind(r.id).execute(&app.db).await.unwrap();
1851 +
                }
1852 +
                return (r, jobs);
1853 +
            }
1854 +
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1855 +
        }
1856 +
        panic!("no run {n}");
1857 +
    };
1858 +
    assert_eq!(newest(h.app.clone(), 1).await.1, ["build", "dist", "lint", "test"]);
1859 +
1860 +
    // Skipped: recorded, finished, no jobs, saying which change and who.
1861 +
    write(work.path(), "Cargo.toml", "version = \"0.1.1\"\n");
1862 +
    c.commit("Bump the version [Skip CI]", &[]).unwrap();
1863 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1864 +
    let (run, jobs) = newest(h.app.clone(), 2).await;
1865 +
    assert_eq!(run.status, "skipped");
1866 +
    assert!(jobs.is_empty(), "{jobs:?}");
1867 +
    assert!(run.error.starts_with("[skip ci] in change ") && run.error.contains(", pushed by "), "{}", run.error);
1868 +
    assert!(run.finished_ms.is_some());
1869 +
    let (_, page) = call(&h.app, "GET", "/andy/skip/flow/2", None, None).await;
1870 +
    assert!(page.contains("the push asked to skip this run"), "{page}");
1871 +
1872 +
    // The next run is scoped against run 1, so it still runs dist for the
1873 +
    // version the skipped push brought.
1874 +
    write(work.path(), "test.sh", "echo still ok\n");
1875 +
    c.commit("Edit the test", &[]).unwrap();
1876 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1877 +
    assert_eq!(newest(h.app.clone(), 3).await.1, ["build", "dist", "lint", "test"], "the skipped bump still runs dist");
1878 +
1879 +
    // Not on the default bookmark: the marker is ignored there.
1880 +
    let row = db::repos::get(&h.app.db, "andy", "skip").await.unwrap().unwrap();
1881 +
    db::repos::set_flow_skip(&h.app.db, row.id, "others").await.unwrap();
1882 +
    write(work.path(), "test.sh", "echo ok again\n");
1883 +
    c.commit("Docs only [skip flow]", &[]).unwrap();
1884 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1885 +
    let (run, jobs) = newest(h.app.clone(), 4).await;
1886 +
    assert_ne!(run.status, "skipped");
1887 +
    assert_eq!(jobs, ["build", "lint", "test"]);
1888 +
    let row = db::repos::get(&h.app.db, "andy", "skip").await.unwrap().unwrap();
1889 +
    assert!(oxygenhub::flow::skip_allowed(&row, "feature") && !oxygenhub::flow::skip_allowed(&row, "main"));
1890 +
    db::repos::set_flow_skip(&h.app.db, row.id, "never").await.unwrap();
1891 +
    let row = db::repos::get(&h.app.db, "andy", "skip").await.unwrap().unwrap();
1892 +
    assert!(!oxygenhub::flow::skip_allowed(&row, "feature"));
1893 +
}
1894 +
1895 +
#[test]
1896 +
fn skip_markers_are_the_forges_spellings_in_any_case() {
1897 +
    use oxygenhub::flow::skip_marker;
1898 +
    assert_eq!(skip_marker("Fix typo [skip flow]"), Some("[skip flow]"));
1899 +
    assert_eq!(skip_marker("[CI SKIP] docs"), Some("[ci skip]"));
1900 +
    assert_eq!(skip_marker("Docs\n\n[Skip CI]"), Some("[skip ci]"));
1901 +
    assert_eq!(skip_marker("skip ci without brackets"), None);
1902 +
}
1903 +
1824 1904
/// oxygenflow#32: a run still going is cancelled from the API, the web or
1825 1905
/// by a newer push to its bookmark; its runner is refused from then on,
1826 1906
/// and the next push is scoped past the cancelled run.
…
4299 unchanged lines
modified docs/spec.md +2 −1
…
291 unchanged lines
292 292
| `/{owner}/{name}/terraform` | Terraform state (§13.5.4): the backend block, how a pipeline and a person authenticate, and the states with their serials, last writer, lock and versions kept |
293 293
| `/{owner}/{name}/trust` | Policy, roots, rules, hub delegate status, verification summary (§6.11) |
294 294
| `/{owner}/{name}/pushes` | Reference update log: who pushed what, when, with which key, result (§6.12) |
295 -
| `/{owner}/{name}/settings/{page}` | A repository's settings (oxygenhub#278), one page per setting, with a sidebar of grouped pages beside it and the current one marked (`aria-current`); on a narrow screen the sidebar is a compact list above the page. **General**: General (`general`; `/settings` shows it), Collaborators (`collaborators`). **Code**: Landing rules (`landing`), Required checks (`checks`), Push protection (`push-protection`). **Flow**: Pipelines (`flow`: OxygenFlow on or off), Runs & priority (`runs`: priority, newest run wins), Gates & agents (`gates`), Runners (`runners`), Variables (`variables`), Secrets (`secrets`), Coverage (`coverage`). **Integrations**: Webhooks (`webhooks`), AI assistants (`assistants`), Git mirror (`git-mirror`), Diffusion (`diffusion`). **Data**: Storage (`storage`, a repository kept in a bucket only), Backup (`backup`). **Danger zone**: Advanced (`advanced`: transfer, archive, delete wiki data, delete). The repository's maintainers have the pages; Collaborators, Runners, Variables, Secrets, Webhooks, AI assistants, Storage and Advanced are its admins' only, left out of the sidebar and not found for others. An archived repository's settings are its admins' only. Each form posts to its own path, as before, and redirects to the page that holds it with a notice or an error; a runner's registration token is shown once on the Runners page. A kept link to the former one-page settings, `/settings#section`, opens the page that has that section (`hub.js`). Names such as *Settings › Push protection* are these pages |
295 +
| `/{owner}/{name}/settings/{page}` | A repository's settings (oxygenhub#278), one page per setting, with a sidebar of grouped pages beside it and the current one marked (`aria-current`); on a narrow screen the sidebar is a compact list above the page. **General**: General (`general`; `/settings` shows it), Collaborators (`collaborators`). **Code**: Landing rules (`landing`), Required checks (`checks`), Push protection (`push-protection`). **Flow**: Pipelines (`flow`: OxygenFlow on or off), Runs & priority (`runs`: priority, newest run wins, pushing without a run), Gates & agents (`gates`), Runners (`runners`), Variables (`variables`), Secrets (`secrets`), Coverage (`coverage`). **Integrations**: Webhooks (`webhooks`), AI assistants (`assistants`), Git mirror (`git-mirror`), Diffusion (`diffusion`). **Data**: Storage (`storage`, a repository kept in a bucket only), Backup (`backup`). **Danger zone**: Advanced (`advanced`: transfer, archive, delete wiki data, delete). The repository's maintainers have the pages; Collaborators, Runners, Variables, Secrets, Webhooks, AI assistants, Storage and Advanced are its admins' only, left out of the sidebar and not found for others. An archived repository's settings are its admins' only. Each form posts to its own path, as before, and redirects to the page that holds it with a notice or an error; a runner's registration token is shown once on the Runners page. A kept link to the former one-page settings, `/settings#section`, opens the page that has that section (`hub.js`). Names such as *Settings › Push protection* are these pages |
296 296
| `/{owner}/{name}/edit/{ref}/{path}`, `/new/{ref}/{path}` | Web edit (§6.9) |
297 297
| `/settings/*` | Profile, security (password, TOTP, sessions), keys (linked identities), tokens, notifications, theme |
298 298
| `/notifications` | Inbox (§10) |
…
219 unchanged lines
518 518
  Runs only ever take committed states already in the repository, never uncommitted work. A run writes no history: its receipts are signed statements about a state (Oxygen spec §14.6). Running a state again adds another statement. The environment hash shows whether it ran in the same environment, and a matching result is independent confirmation. The new run's checks replace the old ones for that state, and the run page says what started it (pushed by, started by, re-run of #N by).
519 519
- **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`.
520 520
- **Newest run wins** (oxygenhub#267). A new run cancels the older runs of the same thing still live, so runs do not pile up behind states that have moved on: a push run cancels the older push runs of its bookmark (Diffusion's bumps start push runs), and a proposal's run (bookmark `proposal/N`, started when its target moves) the older runs of that proposal. Each cancelled job says `superseded by run #N`, and the next push run's scope reaches back past what was cancelled. A proposal's run leaves its source bookmark's push runs alone: they test the source head, another state, whose checks the bookmark and other proposals may want. Tag, manual, scheduled and re-run runs were each asked for in their own right, so they supersede nothing and nothing supersedes them; they are cancelled by hand. A repository's maintainers choose under Settings › OxygenFlow › Newest run wins whether superseded runs already running are cancelled too (the default) or finish, with only the queued ones cancelled (`repositories.flow_keep_running`, audited as `repo.flow_keep_running`).
521 +
- **Pushing without a run** (oxygenhub#284). A push whose newest change (by its clock) has `[skip flow]`, `[skip ci]` or `[ci skip]` in its message, in any case, starts no jobs: the hub records a run with status `skipped`, finished at once, whose note names the marker, the change and who pushed it, so the run list shows the gap. The marker is in a signed message, so who skipped is verifiable. A skipped run is never a later run's baseline: the next push run's scope reaches back past it, so the changes it brought are still tested. A pushed tag always runs. A repository's maintainers choose under Settings › Runs where skipping is allowed: on any bookmark (the default), on any but the default bookmark, or never (`repositories.flow_skip`: `any`, `others`, `never`; audited as `repo.flow_skip`).
521 522
- **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it.
522 523
- **Runners** (Admin → Runners, site administrators).
523 524
  - A runner's page has a Settings form (#98): labels an administrator gives it beside those it reports (a job asking for a label matches either), and a job time limit in minutes below the site's, applied when it claims.
…
363 unchanged lines
modified templates/flow_run.html +3 −1
…
15 unchanged lines
16 16
{% if let Some(q) = queue %}
17 17
<section class="panel"><div class="strip">Waiting for a runner: {{ q.place().unwrap_or_default() }}.{% if !q.running.is_empty() %} Running now: {{ q.running.join(", ") }}.{% endif %}{% if !q.ahead.is_empty() %} Queued ahead: {{ q.ahead.join(", ") }}.{% endif %}{% if q.hidden > 0 %} Also ahead or running: {{ q.hidden }} job{% if q.hidden != 1 %}s{% endif %} in private repositories.{% endif %}</div></section>
18 18
{% endif %}
19 -
{% if run.status == "error" %}
19 +
{% if run.status == "skipped" %}
20 +
<section class="panel"><div class="strip">No jobs ran: the push asked to skip this run ({{ run.error }}). The next run of {{ run.bookmark }} covers what it brought.</div></section>
21 +
{% else if run.status == "error" %}
20 22
<section class="panel"><div class="strip bad">The pipeline is not valid, so nothing ran.</div><pre class="snippet">{{ run.error }}</pre><p class="small mute">Run <code>flow check</code> locally to see the same problems.</p></section>
21 23
{% else %}
22 24
{% if !approvals.is_empty() %}<section class="panel approvals" aria-label="Approvals">
…
47 unchanged lines
modified templates/flow_runs.html +1 −1
…
36 unchanged lines
37 37
  <tr class="st-{{ row.r.status }}">
38 38
    <td class="st"><span class="flow-glyph st-{{ row.r.status }}" title="{{ row.r.status }}">{{ crate::web::flow::glyph(row.r.status) }}</span></td>
39 39
    <td><a href="{{ repo.url }}/flow/{{ row.r.number }}"><strong>#{{ row.r.number }}</strong></a> <span class="small mute">{{ row.r.status }}{% if let Some(p) = row.place %}, {{ p }}{% endif %}</span></td>
40 -
    <td class="flow-dots">{% for (n, s) in row.jobs %}<span class="flow-glyph st-{{ s }}" title="{{ n }}: {{ s }}">{{ crate::web::flow::glyph(s) }}</span>{% endfor %}{% if row.r.status == "error" %}<span class="small bad">pipeline invalid</span>{% endif %}</td>
40 +
    <td class="flow-dots">{% for (n, s) in row.jobs %}<span class="flow-glyph st-{{ s }}" title="{{ n }}: {{ s }}">{{ crate::web::flow::glyph(s) }}</span>{% endfor %}{% if row.r.status == "error" %}<span class="small bad">pipeline invalid</span>{% else if row.r.status == "skipped" %}<span class="small mute" title="{{ row.r.error }}">skipped by its push</span>{% endif %}</td>
41 41
    <td>{% if row.r.bookmark.is_empty() %}<span class="tag" title="Run on one change, not a bookmark">change</span>{% else %}<span class="lbl lbl-bm">{{ row.r.bookmark }}</span>{% endif %}</td>
42 42
    <td><a href="{{ repo.url }}/tree/{{ row.r.heads }}" title="state {{ row.r.state }}"><code>{{ row.r.state|short }}</code></a></td>
43 43
    <td>{{ row.r.actor }}</td>
…
9 unchanged lines
modified templates/repo_settings.html +11 −0
…
162 unchanged lines
163 163
  <label class="check"><input type="checkbox" name="running"{% if !row.flow_keep_running %} checked{% endif %}> Cancel superseded runs already running too (untick to let them finish and cancel only those still queued)</label>
164 164
  <button type="submit">SAVE</button>
165 165
</form>
166 +
<form class="card wide" id="flow-skip" method="post" action="{{ repo.url }}/settings/flow-skip">
167 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
168 +
  <h2>Pushing without a run</h2>
169 +
  <p>A push whose newest change says <code>[skip flow]</code>, <code>[skip ci]</code> or <code>[ci skip]</code> in its message starts no jobs: the run list shows it as skipped, with the change and who pushed it, and the next run still covers what it brought. A pushed tag always runs.</p>
170 +
  <select name="skip" aria-label="Where a push may skip its run">
171 +
    <option value="any"{% if row.flow_skip != "others" && row.flow_skip != "never" %} selected{% endif %}>on any bookmark</option>
172 +
    <option value="others"{% if row.flow_skip == "others" %} selected{% endif %}>on any bookmark but {{ row.default_bookmark }}</option>
173 +
    <option value="never"{% if row.flow_skip == "never" %} selected{% endif %}>never: every push runs</option>
174 +
  </select>
175 +
  <button type="submit">SAVE</button>
176 +
</form>
166 177
{% when Section::Gates %}
167 178
<form class="card wide" id="agent-gates" method="post" action="{{ repo.url }}/settings/agent-gates">
168 179
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
174 unchanged lines