pr_01m47d24b0e6n91zwymwxg0vpx/crates/actions/src/workflow.rs

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