Skip to content

g1t/crates/actions/src/workflow.rs

643 lines26,299 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 runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
356 let labels: Vec<String> =
357 texts(Some(&runs_on)).into_iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default()).collect();
358 // `self-hosted`, or a runner group, sends the job to the workspace's
359 // own runners, which may be Linux, macOS or Windows.
360 let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted"));
361 let mut steps = Vec::new();
362 for (index, step) in steps_raw.iter().enumerate() {
363 let Value::Object(fields) = step else {
364 return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
365 };
366 let step = Step {
367 id: text(fields.get("id")),
368 name: text(fields.get("name")),
369 condition: text(fields.get("if")),
370 uses: text(fields.get("uses")),
371 run: text(fields.get("run")),
372 raw: step.clone(),
373 };
374 match (&step.uses, &step.run) {
375 (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
376 (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
377 _ => {}
378 }
379 if let Some(uses) = &step.uses
380 && let Some((severity, message)) = action_note(uses, fields.get("with").and_then(|with| with.get("cache")).is_some())
381 {
382 note(severity, Some(id), message);
383 }
384 if let Some(shell) = text(fields.get("shell"))
385 && !self_hosted
386 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
387 {
388 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
389 }
390 steps.push(step);
391 }
392 if self_hosted {
393 note(
394 Severity::Info,
395 Some(id),
396 "`self-hosted`: the job runs on one of the workspace's self-hosted runners that has every label in its `runs-on`, and waits until one does.".to_owned(),
397 );
398 } else {
399 for label in &labels {
400 let lower = label.to_ascii_lowercase();
401 if lower.contains("windows") || lower.contains("macos") {
402 note(
403 Severity::Unsupported,
404 Some(id),
405 format!("`runs-on: {label}`: g1t's own runners are Linux only, so this job fails. To run it on a Windows or macOS machine of your own, add a self-hosted runner and use `runs-on: [self-hosted, ...]`."),
406 );
407 }
408 }
409 }
410 if spec.contains_key("services") {
411 note(
412 Severity::Info,
413 Some(id),
414 "`services`: each service runs in Docker beside the steps and is reached at `localhost:<port>`. On g1t's machines it is the job's own Docker Engine, the service is also reached by its name, and two services cannot listen on the same port.".to_owned(),
415 );
416 }
417 if spec.contains_key("container") {
418 note(
419 Severity::Info,
420 Some(id),
421 "`container`: the steps run inside that image, in Docker (on g1t's machines, the job's own Engine), with the workspace at the same path as on the runner (`/home/runner/work`), not `/__w`.".to_owned(),
422 );
423 }
424 if spec.contains_key("environment") {
425 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. Unless it says `deployment: false`, the run records a deployment to it.".to_owned());
426 }
427 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
428 Some(Value::Object(strategy)) => (
429 strategy.get("matrix").cloned(),
430 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
431 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
432 ),
433 _ => (None, true, None),
434 };
435 if uses.as_deref().is_some_and(|uses| !uses.starts_with("./")) {
436 note(
437 Severity::Unsupported,
438 Some(id),
439 "Reusable workflows from other repositories are not called on g1t yet, so this job fails; ones in this repository (`./.g1t/workflows/…`) are.".to_owned(),
440 );
441 }
442 jobs.push(Job {
443 id: id.clone(),
444 name: text(spec.get("name")),
445 needs: texts(spec.get("needs")),
446 condition: text(spec.get("if")),
447 runs_on,
448 matrix,
449 fail_fast,
450 max_parallel,
451 uses,
452 steps,
453 raw: Value::Object(spec.clone()),
454 });
455 }
456 for job in &jobs {
457 for need in &job.needs {
458 if !jobs.iter().any(|other| &other.id == need) {
459 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
460 }
461 }
462 }
463 let workflow = Workflow {
464 name: text(root.get("name")),
465 run_name: text(root.get("run-name")),
466 triggers,
467 env,
468 concurrency,
469 jobs,
470 notes,
471 raw,
472 };
473 if workflow.job_order().len() < workflow.jobs.len() {
474 return Err("The jobs' `needs` go round in a circle.".to_owned());
475 }
476 Ok(workflow)
477}
478
479/// What to say about an action g1t runs differently, if anything.
480/// `caches`: the step sets a `cache` input.
481fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> {
482 if uses.starts_with("docker://") {
483 return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine).")));
484 }
485 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
486 match name.as_str() {
487 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
488 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
489 Severity::Info,
490 format!("`{name}`: g1t keeps the cache per repository: up to 2 GiB an entry and 10 GiB a repository, until it goes 7 days unused, and at most 28 days."),
491 )),
492 "actions/upload-artifact" | "actions/download-artifact" => Some((
493 Severity::Info,
494 format!("`{name}`: g1t keeps artifacts with the run for 14 days, up to 60 MB each."),
495 )),
496 _ if caches && name.starts_with("actions/setup-") => Some((
497 Severity::Warning,
498 format!("`{name}` with `cache:` runs without that cache on g1t. Add an `actions/cache` step for the same effect."),
499 )),
500 _ => None,
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 const CI: &str = r#"
509name: CI
510on:
511 push:
512 branches: [main]
513 paths-ignore: ["docs/**"]
514 pull_request:
515 workflow_dispatch:
516 inputs:
517 debug:
518 type: boolean
519 default: false
520 schedule:
521 - cron: "0 3 * * *"
522concurrency:
523 group: ci-${{ github.ref }}
524 cancel-in-progress: true
525env:
526 CARGO_TERM_COLOR: always
527jobs:
528 test:
529 runs-on: ${{ matrix.os }}
530 strategy:
531 matrix:
532 os: [ubuntu-latest, windows-latest]
533 node: [22, 24]
534 steps:
535 - uses: actions/checkout@v7
536 - uses: actions/setup-node@v7
537 with:
538 node-version: ${{ matrix.node }}
539 - run: npm ci
540 - name: Test
541 run: npm test
542 deploy:
543 needs: test
544 if: github.ref == 'refs/heads/main'
545 runs-on: ubuntu-latest
546 steps:
547 - run: echo deploy
548"#;
549
550 #[test]
551 fn a_whole_workflow_reads() {
552 let workflow = parse(CI).unwrap();
553 assert_eq!(workflow.name.as_deref(), Some("CI"));
554 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
555 let push = workflow.trigger("push").unwrap();
556 assert!(push.branches.allows("main"));
557 assert!(!push.branches.allows("dev"));
558 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
559 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
560 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
561 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
562 assert_eq!(workflow.jobs.len(), 2);
563 assert_eq!(workflow.jobs[1].needs, ["test"]);
564 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7");
565 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
566 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
567 assert_eq!(workflow.job_order(), ["test", "deploy"]);
568 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
569 }
570
571 #[test]
572 fn short_forms_of_on() {
573 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
574 assert_eq!(one.triggers[0].event, "push");
575 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
576 assert_eq!(list.triggers.len(), 2);
577 let pr = list.trigger("pull_request").unwrap();
578 assert!(pr.wants_type(Some("opened")));
579 assert!(pr.wants_type(Some("synchronize")));
580 assert!(!pr.wants_type(Some("closed")));
581 assert!(pr.wants_type(Some("ready_for_review")));
582 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
583 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
584 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
585 }
586
587 #[test]
588 fn notes_say_what_runs_differently() {
589 let workflow = parse(
590 "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",
591 )
592 .unwrap();
593 let unsupported: Vec<&str> =
594 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
595 assert!(unsupported.iter().any(|m| m.contains("`release`")));
596 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
597 assert!(!unsupported.iter().any(|m| m.contains("services")));
598 assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
599 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost")));
600 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
601 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
602 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
603 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node")));
604 assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry")));
605 }
606
607 #[test]
608 fn self_hosted_jobs_may_run_on_any_os() {
609 let workflow = parse(
610 "on: push
611jobs:
612 win:
613 runs-on: [self-hosted, windows]
614 steps:
615 - run: dir
616 shell: pwsh
617 mac:
618 runs-on: { group: Macs, labels: [macos] }
619 steps: [{ run: 'true' }]",
620 )
621 .unwrap();
622 assert!(!workflow.notes.iter().any(|n| n.severity == Severity::Unsupported), "{:?}", workflow.notes);
623 let routed: Vec<&str> = workflow.notes.iter().filter(|n| n.message.starts_with("`self-hosted`")).map(|n| n.message.as_str()).collect();
624 assert_eq!(routed.len(), 2);
625 assert!(routed.iter().all(|m| m.contains("self-hosted runners") && !m.contains("Linux")));
626 }
627
628 #[test]
629 fn mistakes_are_explained() {
630 let problem = |yaml: &str| parse(yaml).unwrap_err();
631 assert!(problem("jobs: {}").contains("`on` is missing"));
632 assert!(problem("on: push").contains("`jobs` is missing"));
633 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
634 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
635 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
636 assert!(
637 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 }]")
638 .contains("circle")
639 );
640 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
641 assert!(problem(": : :").contains("not valid YAML"));
642 }
643}