OXYGENHUB

A changelog check on the WASM tier, a job in Flow's pipeline 0aac9efcbeea

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ce7b3a49f9f7
When
· 2026-11-04 00:14 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
0aac9efcbeea6c5162f1ec8b3b423cd718702bd3af0cd44d5409fc2d33ca2db8
Depends on
010b9a79de79 5544c8998aa7 c3db750ed96e e4260689c506 f29b88af0d9b f3b5b86e23ef
Depended on by
nothing yet
In bookmarks
main
In tags
v0.5.3
Edits
9 atoms, 0 tree operations, 4 files

Browse the tree at this change

replaced .flow/checks.wasm +0 −0

No line changes.

modified .flow/pipeline.kdl +8 −0
…
30 unchanged lines
31 31
    exec ".flow/checks.wasm" "todos" "crates"
32 32
}
33 33
34 +
// The changelog's shape, and a section for the version Cargo.toml says, so
35 +
// a release never finds its notes missing.
36 +
job "changelog" {
37 +
    sandbox "wasm"
38 +
    scope "CHANGELOG.md" "Cargo.toml" ".flow/checks.wasm"
39 +
    exec ".flow/checks.wasm" "changelog" "CHANGELOG.md" "Cargo.toml"
40 +
}
41 +
34 42
job "size" {
35 43
    sandbox "wasm"
36 44
    scope "crates/**" ".flow/checks.wasm"
…
118 unchanged lines
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Added: a `changelog` check on the WASM tier.** `.flow/checks.wasm changelog CHANGELOG.md [MANIFEST]` checks the changelog's shape (`## Unreleased` first if at all, then `## X.Y.Z (YYYY-MM-DD)` sections newest first, none empty) and that the version a `Cargo.toml`, `.properties` or `package.json` says has its section, so a release never finds its notes missing. It is a job of OxygenFlow's pipeline, and of OxygenHub's, Oxygen's and the Android app's, any runner with the WASM tier taking it.
7 8
- **Added: `flow deps check` (#101).** Jobs have no network, so every crate a `Cargo.lock` needs must be vendored in the build image at that version. `flow deps check [Cargo.lock]` says whether it is, before the push: each missing crate with the versions the image does have and the `cargo update --precise` that pins one, exit 1. It reads the image's crate list (`--list`), a vendor directory (`--vendor`), or the lock files the image is built from (`--image-lock`, repeatable); inside a job, the image's own. `flow deps list` prints the set.
8 9
- **Changed: the build image publishes its crate list.** `images/rust/build.sh` writes `crates.txt` (`NAME VERSION` a line) beside the tarball and at `/opt/crates.txt` in the image.
9 10
- **What a runner is running, for its host (oxygenhub-android#8):** `flow::runner::running()` lists the jobs this runner is running now (`owner/name #run job`), each from just before it starts until it ends, so the Android app can show them on its card and notification.
…
133 unchanged lines
modified crates/flow-checks/src/main.rs +141 −1
…
8 unchanged lines
9 9
//!   listed; never fails.
10 10
//! - `size DIR`: lines of Rust in each crate directory under DIR.
11 11
//! - `summary`: one line naming `$PROJECT`.
12 +
//! - `changelog FILE [MANIFEST]`: the changelog's shape: `## Unreleased`
13 +
//!   first if at all, then `## X.Y.Z (YYYY-MM-DD)` sections, newest first,
14 +
//!   none empty; with a manifest (`Cargo.toml`, `*.properties`,
15 +
//!   `package.json`), its version has a section, as a release needs.
12 16
13 17
use std::fs;
14 18
use std::io::{self, Write};
…
6 unchanged lines
21 25
/// How many noted lines `todos` lists.
22 26
const LISTED: usize = 20;
23 27
24 -
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary";
28 +
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary | changelog FILE [MANIFEST]";
25 29
26 30
fn main() -> ExitCode {
27 31
    let args: Vec<String> = std::env::args().skip(1).collect();
…
8 unchanged lines
36 40
                _ => Err(USAGE.to_string()),
37 41
            },
38 42
            "summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out),
43 +
            "changelog" => match rest {
44 +
                [file] => changelog(root, file, None, &mut out),
45 +
                [file, manifest] => changelog(root, file, Some(manifest), &mut out),
46 +
                _ => Err(USAGE.to_string()),
47 +
            },
39 48
            _ => Err(USAGE.to_string()),
40 49
        },
41 50
        None => Err(USAGE.to_string()),
…
70 unchanged lines
112 121
}
113 122
114 123
/// A directory's entries, sorted by name so every runner prints the same.
124 +
/// A version as three numbers, or `None`.
125 +
fn version(s: &str) -> Option<(u64, u64, u64)> {
126 +
    let mut parts = s.split('.').map(|p| p.parse::<u64>().ok());
127 +
    let v = (parts.next()??, parts.next()??, parts.next()??);
128 +
    parts.next().is_none().then_some(v)
129 +
}
130 +
131 +
/// A `YYYY-MM-DD` date with a real month and day.
132 +
fn is_date(s: &str) -> bool {
133 +
    let b = s.as_bytes();
134 +
    b.len() == 10
135 +
        && b[4] == b'-'
136 +
        && b[7] == b'-'
137 +
        && s[..4].parse::<u32>().is_ok()
138 +
        && s[5..7].parse::<u32>().is_ok_and(|m| (1..=12).contains(&m))
139 +
        && s[8..].parse::<u32>().is_ok_and(|d| (1..=31).contains(&d))
140 +
}
141 +
142 +
/// The version a manifest declares: `version = "…"` in `Cargo.toml`'s
143 +
/// `[package]` or `[workspace.package]`, a `….version=` key of a
144 +
/// `.properties` file, or `"version"` in a `package.json`.
145 +
fn manifest_version(path: &str, text: &str) -> Option<String> {
146 +
    let unquote = |v: &str| v.trim().trim_matches('"').trim_end_matches(',').trim_matches('"').to_string();
147 +
    if path.ends_with(".properties") {
148 +
        return text.lines().find_map(|l| {
149 +
            let (k, v) = l.split_once('=')?;
150 +
            (k.trim() == "version" || k.trim().ends_with(".version")).then(|| v.trim().to_string())
151 +
        });
152 +
    }
153 +
    if path.ends_with(".json") {
154 +
        return text.lines().find_map(|l| l.trim().strip_prefix("\"version\":").map(unquote));
155 +
    }
156 +
    let mut section = "";
157 +
    for l in text.lines() {
158 +
        let l = l.trim();
159 +
        if l.starts_with('[') {
160 +
            section = l;
161 +
        } else if matches!(section, "[package]" | "[workspace.package]") {
162 +
            if let Some(v) = l.strip_prefix("version").and_then(|r| r.trim_start().strip_prefix('=')) {
163 +
                return Some(unquote(v));
164 +
            }
165 +
        }
166 +
    }
167 +
    None
168 +
}
169 +
170 +
/// The changelog's shape, and the manifest's version in it.
171 +
fn changelog(root: &Path, file: &str, manifest: Option<&String>, out: &mut impl Write) -> Done {
172 +
    let text = fs::read_to_string(root.join(file)).map_err(|e| format!("{file}: {e}"))?;
173 +
    let mut sections: Vec<(usize, String, usize)> = Vec::new(); // (line, heading, entries)
174 +
    for (i, line) in text.lines().enumerate() {
175 +
        if let Some(h) = line.strip_prefix("## ") {
176 +
            sections.push((i + 1, h.trim().to_string(), 0));
177 +
        } else if let Some(last) = sections.last_mut() {
178 +
            if line.trim_start().starts_with("- ") || line.trim_start().starts_with("* ") {
179 +
                last.2 += 1;
180 +
            }
181 +
        }
182 +
    }
183 +
    let mut problems = Vec::new();
184 +
    let mut last: Option<(u64, u64, u64)> = None;
185 +
    let mut released = Vec::new();
186 +
    for (k, (line, h, n)) in sections.iter().enumerate() {
187 +
        if h == "Unreleased" {
188 +
            if k != 0 {
189 +
                problems.push(format!("{file}:{line}: ## Unreleased comes first"));
190 +
            }
191 +
            continue;
192 +
        }
193 +
        let (v, rest) = h.split_once(' ').unwrap_or((h.as_str(), ""));
194 +
        let date = rest.trim().strip_prefix('(').and_then(|r| r.strip_suffix(')'));
195 +
        match (version(v), date) {
196 +
            (Some(ver), Some(d)) if is_date(d) => {
197 +
                if last.is_some_and(|l| ver >= l) {
198 +
                    problems.push(format!("{file}:{line}: {v} is not older than the section above it"));
199 +
                }
200 +
                last = Some(ver);
201 +
                released.push(v.to_string());
202 +
                if *n == 0 {
203 +
                    problems.push(format!("{file}:{line}: ## {h} has no entries"));
204 +
                }
205 +
            }
206 +
            _ => problems.push(format!("{file}:{line}: \"## {h}\" is not \"## X.Y.Z (YYYY-MM-DD)\" or \"## Unreleased\"")),
207 +
        }
208 +
    }
209 +
    let mut said = format!("changelog: {} released section{}", released.len(), if released.len() == 1 { "" } else { "s" });
210 +
    if let Some(m) = manifest {
211 +
        let mtext = fs::read_to_string(root.join(m)).map_err(|e| format!("{m}: {e}"))?;
212 +
        match manifest_version(m, &mtext) {
213 +
            Some(v) if released.contains(&v) => said.push_str(&format!(", {m} at {v} has its section")),
214 +
            Some(v) => problems.push(format!(
215 +
                "{m} says version {v}, but {file} has no \"## {v} (YYYY-MM-DD)\" section: a release of it would have no notes"
216 +
            )),
217 +
            None => problems.push(format!("{m}: no version found")),
218 +
        }
219 +
    }
220 +
    if problems.is_empty() {
221 +
        return emit(out, &said);
222 +
    }
223 +
    Err(problems.join("\n"))
224 +
}
225 +
115 226
fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> {
116 227
    let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
117 228
    let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?;
…
47 unchanged lines
165 276
166 277
    fn args(a: &[&str]) -> Vec<String> {
167 278
        a.iter().map(|s| s.to_string()).collect()
279 +
    }
280 +
281 +
    #[test]
282 +
    fn changelog_shape_and_the_manifest_version() {
283 +
        let good = "# Changelog\n\nIntro.\n\n## Unreleased\n\n## 0.2.0 (2026-11-03)\n\n- **New.**\n\n## 0.1.9 (2026-10-01)\n\n- Old.\n";
284 +
        let dir = tree(&[
285 +
            ("CHANGELOG.md", good),
286 +
            ("Cargo.toml", "[workspace]\nmembers = []\n\n[workspace.package]\nversion = \"0.2.0\"\n"),
287 +
            ("gradle.properties", "android.useAndroidX=true\noxygenhub.version=0.1.9\n"),
288 +
            ("package.json", "{\n  \"name\": \"x\",\n  \"version\": \"0.3.0\",\n}\n"),
289 +
        ]);
290 +
        let r = |m: Option<&str>| run(|o| changelog(dir.path(), "CHANGELOG.md", m.map(String::from).as_ref(), o));
291 +
        let (done, out) = r(None);
292 +
        assert_eq!((done, out.trim()), (Ok(()), "changelog: 2 released sections"));
293 +
        assert!(r(Some("Cargo.toml")).1.contains("Cargo.toml at 0.2.0 has its section"));
294 +
        assert!(r(Some("gradle.properties")).0.is_ok(), "an older release's section counts");
295 +
        let e = r(Some("package.json")).0.unwrap_err();
296 +
        assert!(e.contains("version 0.3.0") && e.contains("no \"## 0.3.0 (YYYY-MM-DD)\""), "{e}");
297 +
        let bad = "## 0.1.0 (2026-10-01)\n\n- a\n\n## Unreleased\n\n## 0.2.0 (2026-13-01)\n\n- b\n\n## 0.0.9 (2026-09-01)\n\n## v1\n";
298 +
        let dir = tree(&[("CHANGELOG.md", bad)]);
299 +
        let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err();
300 +
        assert!(e.contains(":5: ## Unreleased comes first"), "{e}");
301 +
        assert!(e.contains(":7: \"## 0.2.0 (2026-13-01)\" is not"), "a month 13: {e}");
302 +
        assert!(e.contains(":11: ## 0.0.9 (2026-09-01) has no entries"), "{e}");
303 +
        assert!(e.contains(":13: \"## v1\""), "{e}");
304 +
        let order = "## 0.1.0 (2026-10-01)\n\n- a\n\n## 0.2.0 (2026-11-01)\n\n- b\n";
305 +
        let dir = tree(&[("CHANGELOG.md", order)]);
306 +
        let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err();
307 +
        assert!(e.contains("0.2.0 is not older than the section above it"), "{e}");
168 308
    }
169 309
170 310
    #[test]
…
52 unchanged lines