flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/actions/src/workflow.rs

599 lines23,991 bytesCodeBlame
1//! Reading a workflow file: its triggers, jobs and steps, and notes on
2//! anything in it that runs differently on g1t, so moving a repository
3//! from GitHub says plainly what to expect.
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7
8use crate::filter::{Filter, Patterns};
9
10/// Where workflows live: GitHub's `.github/workflows`, under g1t's own
11/// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
12/// g1t never reads `.github`, which stays GitHub's.
13pub const FOLDER: &str = ".g1t/workflows";
14
15/// The events a workflow can name that g1t starts runs for.
16pub const SUPPORTED_EVENTS: &[&str] = &[
17 "push",
18 "pull_request",
19 "pull_request_target",
20 "pull_request_review",
21 "issues",
22 "issue_comment",
23 "schedule",
24 "workflow_dispatch",
25 "repository_dispatch",
26 "workflow_call",
27 "workflow_run",
28 "merge_group",
29 "create",
30 "delete",
31];
32
33/// The `types` each event has when a workflow gives none, as on GitHub.
34pub fn default_types(event: &str) -> &'static [&'static str] {
35 match event {
36 "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"],
37 "merge_group" => &["checks_requested"],
38 _ => &[],
39 }
40}
41
42/// How much a note matters.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum Severity {
46 /// Runs, slightly differently.
47 Info,
48 /// Runs, but something in it does nothing or may not work.
49 Warning,
50 /// Does not run on g1t.
51 Unsupported,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub struct Note {
56 pub severity: Severity,
57 /// The job, if the note is about one.
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub job: Option<String>,
60 pub message: String,
61}
62
63/// One event a workflow is started by, with its filters.
64#[derive(Clone, Debug, Default, PartialEq, Eq)]
65pub struct Trigger {
66 pub event: String,
67 /// Activity types; empty means the event's defaults (or all).
68 pub types: Vec<String>,
69 pub branches: Filter,
70 pub tags: Filter,
71 pub paths: Filter,
72 /// For `schedule`.
73 pub crons: Vec<String>,
74 /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
75 pub inputs: Map<String, Value>,
76 /// For `workflow_run`: the names of the workflows it follows.
77 pub workflows: Vec<String>,
78}
79
80impl Trigger {
81 /// Whether an activity type starts it.
82 pub fn wants_type(&self, action: Option<&str>) -> bool {
83 let Some(action) = action else { return true };
84 if self.types.is_empty() {
85 // A g1t agent's pull request has no code until it is marked
86 // ready, so that is when its default runs start, as `opened`
87 // would on GitHub.
88 if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" {
89 return true;
90 }
91 let defaults = default_types(&self.event);
92 return defaults.is_empty() || defaults.contains(&action);
93 }
94 self.types.iter().any(|t| t == action)
95 }
96}
97
98#[derive(Clone, Debug, PartialEq)]
99pub struct Step {
100 pub id: Option<String>,
101 pub name: Option<String>,
102 pub condition: Option<String>,
103 pub uses: Option<String>,
104 pub run: Option<String>,
105 /// The whole step as written, for the sandbox.
106 pub raw: Value,
107}
108
109impl Step {
110 /// How the step is shown when it has no name.
111 pub fn title(&self) -> String {
112 if let Some(name) = &self.name {
113 return name.clone();
114 }
115 if let Some(uses) = &self.uses {
116 return format!("Run {uses}");
117 }
118 let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
119 format!("Run {}", first.trim())
120 }
121}
122
123#[derive(Clone, Debug, PartialEq)]
124pub struct Job {
125 /// Its key under `jobs:`.
126 pub id: String,
127 pub name: Option<String>,
128 pub needs: Vec<String>,
129 pub condition: Option<String>,
130 pub runs_on: Value,
131 /// `strategy.matrix`, as written (it may be an expression).
132 pub matrix: Option<Value>,
133 pub fail_fast: bool,
134 pub max_parallel: Option<u32>,
135 /// A reusable workflow it calls (`uses:` on a job).
136 pub uses: Option<String>,
137 pub steps: Vec<Step>,
138 /// The whole job as written, for the sandbox.
139 pub raw: Value,
140}
141
142#[derive(Clone, Debug, PartialEq)]
143pub struct Workflow {
144 pub name: Option<String>,
145 pub run_name: Option<String>,
146 pub triggers: Vec<Trigger>,
147 pub env: Map<String, Value>,
148 pub concurrency: Option<Concurrency>,
149 pub jobs: Vec<Job>,
150 pub notes: Vec<Note>,
151 /// The whole workflow as written.
152 pub raw: Value,
153}
154
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub struct Concurrency {
157 /// May hold an expression.
158 pub group: String,
159 pub cancel_in_progress: Value,
160}
161
162impl Workflow {
163 pub fn trigger(&self, event: &str) -> Option<&Trigger> {
164 self.triggers.iter().find(|trigger| trigger.event == event)
165 }
166
167 /// The name shown for it: its `name`, or its file's path.
168 pub fn display_name(&self, path: &str) -> String {
169 self.name.clone().unwrap_or_else(|| path.to_owned())
170 }
171
172 /// The job ids in an order where each comes after the jobs it needs.
173 pub fn job_order(&self) -> Vec<&str> {
174 let mut ordered: Vec<&str> = Vec::new();
175 while ordered.len() < self.jobs.len() {
176 let before = ordered.len();
177 for job in &self.jobs {
178 if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
179 ordered.push(&job.id);
180 }
181 }
182 if ordered.len() == before {
183 break;
184 }
185 }
186 ordered
187 }
188}
189
190/// YAML to JSON, keeping the order of keys. Keys that are not strings
191/// (`on: true` in YAML 1.1, numbers) become their text.
192pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
193 match value {
194 serde_yaml::Value::Null => Value::Null,
195 serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
196 serde_yaml::Value::Number(number) => {
197 if let Some(n) = number.as_i64() {
198 Value::from(n)
199 } else if let Some(n) = number.as_u64() {
200 Value::from(n)
201 } else {
202 number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
203 }
204 }
205 serde_yaml::Value::String(text) => Value::String(text.clone()),
206 serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
207 serde_yaml::Value::Mapping(map) => {
208 let mut out = Map::new();
209 for (key, value) in map {
210 let key = match key {
211 serde_yaml::Value::String(text) => text.clone(),
212 serde_yaml::Value::Bool(flag) => flag.to_string(),
213 serde_yaml::Value::Number(number) => number.to_string(),
214 _ => continue,
215 };
216 out.insert(key, yaml_to_json(value));
217 }
218 Value::Object(out)
219 }
220 serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
221 }
222}
223
224fn texts(value: Option<&Value>) -> Vec<String> {
225 match value {
226 Some(Value::String(text)) => vec![text.clone()],
227 Some(Value::Array(items)) => items
228 .iter()
229 .filter_map(|item| match item {
230 Value::String(text) => Some(text.clone()),
231 Value::Number(n) => Some(n.to_string()),
232 _ => None,
233 })
234 .collect(),
235 _ => Vec::new(),
236 }
237}
238
239fn text(value: Option<&Value>) -> Option<String> {
240 match value? {
241 Value::String(text) => Some(text.clone()),
242 Value::Number(n) => Some(n.to_string()),
243 Value::Bool(flag) => Some(flag.to_string()),
244 _ => None,
245 }
246}
247
248fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
249 let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
250 Filter { only: list(only), ignore: list(ignore) }
251}
252
253fn trigger(event: &str, spec: &Value) -> Trigger {
254 let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
255 match spec {
256 Value::Object(spec) => {
257 trigger.types = texts(spec.get("types"));
258 trigger.branches = filter(spec, "branches", "branches-ignore");
259 trigger.tags = filter(spec, "tags", "tags-ignore");
260 trigger.paths = filter(spec, "paths", "paths-ignore");
261 if let Some(Value::Object(inputs)) = spec.get("inputs") {
262 trigger.inputs = inputs.clone();
263 }
264 trigger.workflows = texts(spec.get("workflows"));
265 }
266 Value::Array(entries) if event == "schedule" => {
267 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
268 }
269 _ => {}
270 }
271 trigger
272}
273
274/// Reads a workflow. `Err` is what is wrong with the file, for the person
275/// who wrote it; what reads but runs differently is in `notes`.
276pub fn parse(source: &str) -> Result<Workflow, String> {
277 let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
278 let raw = yaml_to_json(&yaml);
279 let Value::Object(root) = &raw else {
280 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
281 };
282 let mut notes = Vec::new();
283 let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
284
285 // `on`, in any of its three shapes. YAML 1.1 readers turn `on` into
286 // `true`; this reader keeps it, and accepts both.
287 let on = root.get("on").or_else(|| root.get("true")).ok_or("`on` is missing: say which events start the workflow.")?;
288 let mut triggers = Vec::new();
289 match on {
290 Value::String(event) => triggers.push(trigger(event, &Value::Null)),
291 Value::Array(events) => {
292 for event in events {
293 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
294 triggers.push(trigger(event, &Value::Null));
295 }
296 }
297 Value::Object(events) => {
298 for (event, spec) in events {
299 triggers.push(trigger(event, spec));
300 }
301 }
302 _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
303 }
304 for trigger in &triggers {
305 if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
306 note(
307 Severity::Unsupported,
308 None,
309 format!("g1t has no `{}` event, so that trigger never starts it.", trigger.event),
310 );
311 }
312 if trigger.event == "pull_request_target" {
313 note(
314 Severity::Info,
315 None,
316 "`pull_request_target` runs like `pull_request`, on the pull request's head, with the repository's secrets.".to_owned(),
317 );
318 }
319 if trigger.event == "workflow_call" && triggers.len() == 1 {
320 note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
321 }
322 }
323
324 let env = match root.get("env") {
325 Some(Value::Object(env)) => env.clone(),
326 _ => Map::new(),
327 };
328 let concurrency = match root.get("concurrency") {
329 Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
330 Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
331 group,
332 cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
333 }),
334 _ => None,
335 };
336
337 let Some(Value::Object(job_specs)) = root.get("jobs") else {
338 return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
339 };
340 if job_specs.is_empty() {
341 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
342 }
343 let mut jobs = Vec::new();
344 for (id, spec) in job_specs {
345 let Value::Object(spec) = spec else {
346 return Err(format!("Job `{id}` is a mapping."));
347 };
348 let uses = text(spec.get("uses"));
349 let steps_raw = match spec.get("steps") {
350 Some(Value::Array(steps)) => steps.clone(),
351 None if uses.is_some() => Vec::new(),
352 None => return Err(format!("Job `{id}` has no `steps`.")),
353 Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
354 };
355 let mut steps = Vec::new();
356 for (index, step) in steps_raw.iter().enumerate() {
357 let Value::Object(fields) = step else {
358 return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
359 };
360 let step = Step {
361 id: text(fields.get("id")),
362 name: text(fields.get("name")),
363 condition: text(fields.get("if")),
364 uses: text(fields.get("uses")),
365 run: text(fields.get("run")),
366 raw: step.clone(),
367 };
368 match (&step.uses, &step.run) {
369 (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
370 (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
371 _ => {}
372 }
373 if let Some(uses) = &step.uses
374 && let Some((severity, message)) = action_note(uses, fields.get("with").and_then(|with| with.get("cache")).is_some())
375 {
376 note(severity, Some(id), message);
377 }
378 if let Some(shell) = text(fields.get("shell"))
379 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
380 {
381 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
382 }
383 steps.push(step);
384 }
385 let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
386 for label in texts(Some(&runs_on)).iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default().iter()) {
387 let lower = label.to_ascii_lowercase();
388 if lower.contains("windows") || lower.contains("macos") {
389 note(
390 Severity::Unsupported,
391 Some(id),
392 format!("`runs-on: {label}`: g1t runs jobs on Linux only, so this job fails."),
393 );
394 } else if lower == "self-hosted" {
395 note(Severity::Info, Some(id), "`self-hosted`: g1t runs it on its own Linux runner.".to_owned());
396 }
397 }
398 if spec.contains_key("services") {
399 note(Severity::Unsupported, Some(id), "`services` containers (such as a database) are not started on g1t yet.".to_owned());
400 }
401 if spec.contains_key("container") {
402 note(Severity::Warning, Some(id), "`container`: steps run on g1t's runner image instead of that container.".to_owned());
403 }
404 if spec.contains_key("environment") {
405 note(Severity::Info, Some(id), "`environment`: the job gets the values its secrets and variables give this environment; protection rules (approvals, wait timers, branch limits) are not enforced on g1t yet.".to_owned());
406 }
407 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
408 Some(Value::Object(strategy)) => (
409 strategy.get("matrix").cloned(),
410 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
411 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
412 ),
413 _ => (None, true, None),
414 };
415 if uses.as_deref().is_some_and(|uses| !uses.starts_with("./")) {
416 note(
417 Severity::Unsupported,
418 Some(id),
419 "Reusable workflows from other repositories are not called on g1t yet, so this job fails; ones in this repository (`./.g1t/workflows/…`) are.".to_owned(),
420 );
421 }
422 jobs.push(Job {
423 id: id.clone(),
424 name: text(spec.get("name")),
425 needs: texts(spec.get("needs")),
426 condition: text(spec.get("if")),
427 runs_on,
428 matrix,
429 fail_fast,
430 max_parallel,
431 uses,
432 steps,
433 raw: Value::Object(spec.clone()),
434 });
435 }
436 for job in &jobs {
437 for need in &job.needs {
438 if !jobs.iter().any(|other| &other.id == need) {
439 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
440 }
441 }
442 }
443 let workflow = Workflow {
444 name: text(root.get("name")),
445 run_name: text(root.get("run-name")),
446 triggers,
447 env,
448 concurrency,
449 jobs,
450 notes,
451 raw,
452 };
453 if workflow.job_order().len() < workflow.jobs.len() {
454 return Err("The jobs' `needs` go round in a circle.".to_owned());
455 }
456 Ok(workflow)
457}
458
459/// What to say about an action g1t runs differently, if anything.
460/// `caches`: the step sets a `cache` input.
461fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> {
462 if uses.starts_with("docker://") {
463 return Some((Severity::Unsupported, format!("`{uses}`: Docker actions do not run on g1t yet.")));
464 }
465 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
466 match name.as_str() {
467 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
468 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
469 Severity::Info,
470 format!("`{name}`: g1t keeps the cache per repository for 7 days, up to 60 MB an entry."),
471 )),
472 "actions/upload-artifact" | "actions/download-artifact" => Some((
473 Severity::Info,
474 format!("`{name}`: g1t keeps artifacts with the run for 14 days, up to 60 MB each."),
475 )),
476 _ if caches && name.starts_with("actions/setup-") => Some((
477 Severity::Warning,
478 format!("`{name}` with `cache:` runs without that cache on g1t. Add an `actions/cache` step for the same effect."),
479 )),
480 _ => None,
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 const CI: &str = r#"
489name: CI
490on:
491 push:
492 branches: [main]
493 paths-ignore: ["docs/**"]
494 pull_request:
495 workflow_dispatch:
496 inputs:
497 debug:
498 type: boolean
499 default: false
500 schedule:
501 - cron: "0 3 * * *"
502concurrency:
503 group: ci-${{ github.ref }}
504 cancel-in-progress: true
505env:
506 CARGO_TERM_COLOR: always
507jobs:
508 test:
509 runs-on: ${{ matrix.os }}
510 strategy:
511 matrix:
512 os: [ubuntu-latest, windows-latest]
513 node: [22, 24]
514 steps:
515 - uses: actions/checkout@v7
516 - uses: actions/setup-node@v7
517 with:
518 node-version: ${{ matrix.node }}
519 - run: npm ci
520 - name: Test
521 run: npm test
522 deploy:
523 needs: test
524 if: github.ref == 'refs/heads/main'
525 runs-on: ubuntu-latest
526 steps:
527 - run: echo deploy
528"#;
529
530 #[test]
531 fn a_whole_workflow_reads() {
532 let workflow = parse(CI).unwrap();
533 assert_eq!(workflow.name.as_deref(), Some("CI"));
534 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
535 let push = workflow.trigger("push").unwrap();
536 assert!(push.branches.allows("main"));
537 assert!(!push.branches.allows("dev"));
538 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
539 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
540 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
541 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
542 assert_eq!(workflow.jobs.len(), 2);
543 assert_eq!(workflow.jobs[1].needs, ["test"]);
544 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7");
545 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
546 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
547 assert_eq!(workflow.job_order(), ["test", "deploy"]);
548 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
549 }
550
551 #[test]
552 fn short_forms_of_on() {
553 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
554 assert_eq!(one.triggers[0].event, "push");
555 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
556 assert_eq!(list.triggers.len(), 2);
557 let pr = list.trigger("pull_request").unwrap();
558 assert!(pr.wants_type(Some("opened")));
559 assert!(pr.wants_type(Some("synchronize")));
560 assert!(!pr.wants_type(Some("closed")));
561 assert!(pr.wants_type(Some("ready_for_review")));
562 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
563 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
564 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
565 }
566
567 #[test]
568 fn notes_say_what_runs_differently() {
569 let workflow = parse(
570 "on: [push, release]\njobs:\n win:\n runs-on: windows-latest\n services:\n db: { image: postgres }\n steps:\n - uses: actions/cache@v6\n - uses: actions/setup-node@v7\n with: { cache: npm }\n - uses: docker://alpine\n - run: dir\n shell: pwsh",
571 )
572 .unwrap();
573 let unsupported: Vec<&str> =
574 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
575 assert!(unsupported.iter().any(|m| m.contains("`release`")));
576 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
577 assert!(unsupported.iter().any(|m| m.contains("services")));
578 assert!(unsupported.iter().any(|m| m.contains("docker://alpine")));
579 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
580 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
581 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node")));
582 }
583
584 #[test]
585 fn mistakes_are_explained() {
586 let problem = |yaml: &str| parse(yaml).unwrap_err();
587 assert!(problem("jobs: {}").contains("`on` is missing"));
588 assert!(problem("on: push").contains("`jobs` is missing"));
589 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
590 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
591 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
592 assert!(
593 problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]\n b:\n needs: a\n runs-on: x\n steps: [{ run: x }]")
594 .contains("circle")
595 );
596 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
597 assert!(problem(": : :").contains("not valid YAML"));
598 }
599}