pr_01m47d24b0e6n91zwymwxg0vpx/crates/actions/src/workflow.rs

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