Skip to content
797 linesCodeBlameRaw
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};
9use crate::permissions::{self, Permissions};
10
11/// Where workflows live: GitHub's `.github/workflows`, under g1t's own
12/// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
13/// g1t reads `.github` only for a mirror of a GitHub repository in CI
14/// failover or taken over (services/actions/src/mirrored.rs); otherwise it
15/// stays GitHub's.
16pub const FOLDER: &str = ".g1t/workflows";
17
18/// The events a workflow can name that g1t starts runs for.
19pub const SUPPORTED_EVENTS: &[&str] = &[
20 "push",
21 "pull_request",
22 "pull_request_target",
23 "pull_request_review",
24 "issues",
25 "issue_comment",
26 "schedule",
27 "workflow_dispatch",
28 "repository_dispatch",
29 "workflow_call",
30 "workflow_run",
31 "merge_group",
32 "create",
33 "delete",
34 "release",
35 "deployment",
36 "deployment_status",
37];
38
39/// Events GitHub has that g1t knows of but never sends: a workflow on one
40/// of them is told so, rather than waiting for a run that never comes.
41pub const UNSENT_EVENTS: &[(&str, &str)] = &[(
42 "label",
43 "g1t does not start runs when a label is created, edited or deleted yet, so the `label` trigger never starts it. Putting a label on an issue or a pull request starts `issues` and `pull_request` workflows with the `labeled` type.",
44)];
45
46/// The `types` each event has when a workflow gives none, as on GitHub.
47pub fn default_types(event: &str) -> &'static [&'static str] {
48 match event {
49 "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"],
50 "merge_group" => &["checks_requested"],
51 _ => &[],
52 }
53}
54
55/// How much a note matters.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum Severity {
59 /// Runs, slightly differently.
60 Info,
61 /// Runs, but something in it does nothing or may not work.
62 Warning,
63 /// Does not run on g1t.
64 Unsupported,
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
68pub struct Note {
69 pub severity: Severity,
70 /// The job, if the note is about one.
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub job: Option<String>,
73 pub message: String,
74}
75
76/// One event a workflow is started by, with its filters.
77#[derive(Clone, Debug, Default, PartialEq, Eq)]
78pub struct Trigger {
79 pub event: String,
80 /// Activity types; empty means the event's defaults (or all).
81 pub types: Vec<String>,
82 pub branches: Filter,
83 pub tags: Filter,
84 pub paths: Filter,
85 /// For `schedule`.
86 pub crons: Vec<String>,
87 /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
88 pub inputs: Map<String, Value>,
89 /// For `workflow_run`: the names of the workflows it follows.
90 pub workflows: Vec<String>,
91}
92
93impl Trigger {
94 /// Whether an activity type starts it.
95 pub fn wants_type(&self, action: Option<&str>) -> bool {
96 let Some(action) = action else { return true };
97 if self.types.is_empty() {
98 // A g1t agent's pull request has no code until it is marked
99 // ready, so that is when its default runs start, as `opened`
100 // would on GitHub.
101 if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" {
102 return true;
103 }
104 let defaults = default_types(&self.event);
105 return defaults.is_empty() || defaults.contains(&action);
106 }
107 self.types.iter().any(|t| t == action)
108 }
109}
110
111#[derive(Clone, Debug, PartialEq)]
112pub struct Step {
113 pub id: Option<String>,
114 pub name: Option<String>,
115 pub condition: Option<String>,
116 pub uses: Option<String>,
117 pub run: Option<String>,
118 /// The whole step as written, for the sandbox.
119 pub raw: Value,
120}
121
122impl Step {
123 /// How the step is shown when it has no name.
124 pub fn title(&self) -> String {
125 if let Some(name) = &self.name {
126 return name.clone();
127 }
128 if let Some(uses) = &self.uses {
129 return format!("Run {uses}");
130 }
131 let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
132 format!("Run {}", first.trim())
133 }
134}
135
136#[derive(Clone, Debug, PartialEq)]
137pub struct Job {
138 /// Its key under `jobs:`.
139 pub id: String,
140 pub name: Option<String>,
141 pub needs: Vec<String>,
142 pub condition: Option<String>,
143 pub runs_on: Value,
144 /// `strategy.matrix`, as written (it may be an expression).
145 pub matrix: Option<Value>,
146 pub fail_fast: bool,
147 pub max_parallel: Option<u32>,
148 /// A reusable workflow it calls (`uses:` on a job).
149 pub uses: Option<String>,
150 /// Its own `permissions`, which replace the workflow's.
151 pub permissions: Option<Permissions>,
152 /// Its own `concurrency`: at most one job of its group runs at a time.
153 pub concurrency: Option<Concurrency>,
154 pub steps: Vec<Step>,
155 /// The whole job as written, for the sandbox.
156 pub raw: Value,
157}
158
159impl Job {
160 /// What its token may do: its own `permissions`, else its workflow's,
161 /// else `default` (the repository's choice).
162 pub fn permissions(&self, workflow: &Workflow, default: permissions::TokenDefault) -> Permissions {
163 self.permissions
164 .clone()
165 .or_else(|| workflow.permissions.clone())
166 .unwrap_or_else(|| Permissions::default_for(default))
167 }
168}
169
170#[derive(Clone, Debug, PartialEq)]
171pub struct Workflow {
172 pub name: Option<String>,
173 pub run_name: Option<String>,
174 pub triggers: Vec<Trigger>,
175 pub env: Map<String, Value>,
176 pub concurrency: Option<Concurrency>,
177 /// Its top-level `permissions`, for every job that writes none.
178 pub permissions: Option<Permissions>,
179 pub jobs: Vec<Job>,
180 pub notes: Vec<Note>,
181 /// The whole workflow as written.
182 pub raw: Value,
183}
184
185#[derive(Clone, Debug, PartialEq, Eq)]
186pub struct Concurrency {
187 /// May hold an expression.
188 pub group: String,
189 pub cancel_in_progress: Value,
190}
191
192impl Workflow {
193 pub fn trigger(&self, event: &str) -> Option<&Trigger> {
194 self.triggers.iter().find(|trigger| trigger.event == event)
195 }
196
197 /// The name shown for it: its `name`, or its file's path.
198 pub fn display_name(&self, path: &str) -> String {
199 self.name.clone().unwrap_or_else(|| path.to_owned())
200 }
201
202 /// The job ids in an order where each comes after the jobs it needs.
203 pub fn job_order(&self) -> Vec<&str> {
204 let mut ordered: Vec<&str> = Vec::new();
205 while ordered.len() < self.jobs.len() {
206 let before = ordered.len();
207 for job in &self.jobs {
208 if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
209 ordered.push(&job.id);
210 }
211 }
212 if ordered.len() == before {
213 break;
214 }
215 }
216 ordered
217 }
218}
219
220/// YAML to JSON, keeping the order of keys. Keys that are not strings
221/// (`on: true` in YAML 1.1, numbers) become their text.
222pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
223 match value {
224 serde_yaml::Value::Null => Value::Null,
225 serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
226 serde_yaml::Value::Number(number) => {
227 if let Some(n) = number.as_i64() {
228 Value::from(n)
229 } else if let Some(n) = number.as_u64() {
230 Value::from(n)
231 } else {
232 number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
233 }
234 }
235 serde_yaml::Value::String(text) => Value::String(text.clone()),
236 serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
237 serde_yaml::Value::Mapping(map) => {
238 let mut out = Map::new();
239 for (key, value) in map {
240 let key = match key {
241 serde_yaml::Value::String(text) => text.clone(),
242 serde_yaml::Value::Bool(flag) => flag.to_string(),
243 serde_yaml::Value::Number(number) => number.to_string(),
244 _ => continue,
245 };
246 out.insert(key, yaml_to_json(value));
247 }
248 Value::Object(out)
249 }
250 serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
251 }
252}
253
254fn texts(value: Option<&Value>) -> Vec<String> {
255 match value {
256 Some(Value::String(text)) => vec![text.clone()],
257 Some(Value::Array(items)) => items
258 .iter()
259 .filter_map(|item| match item {
260 Value::String(text) => Some(text.clone()),
261 Value::Number(n) => Some(n.to_string()),
262 _ => None,
263 })
264 .collect(),
265 _ => Vec::new(),
266 }
267}
268
269fn text(value: Option<&Value>) -> Option<String> {
270 match value? {
271 Value::String(text) => Some(text.clone()),
272 Value::Number(n) => Some(n.to_string()),
273 Value::Bool(flag) => Some(flag.to_string()),
274 _ => None,
275 }
276}
277
278fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
279 let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
280 Filter { only: list(only), ignore: list(ignore) }
281}
282
283fn trigger(event: &str, spec: &Value) -> Trigger {
284 let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
285 match spec {
286 Value::Object(spec) => {
287 trigger.types = texts(spec.get("types"));
288 trigger.branches = filter(spec, "branches", "branches-ignore");
289 trigger.tags = filter(spec, "tags", "tags-ignore");
290 trigger.paths = filter(spec, "paths", "paths-ignore");
291 if let Some(Value::Object(inputs)) = spec.get("inputs") {
292 trigger.inputs = inputs.clone();
293 }
294 trigger.workflows = texts(spec.get("workflows"));
295 }
296 Value::Array(entries) if event == "schedule" => {
297 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
298 }
299 _ => {}
300 }
301 trigger
302}
303
304/// Reads a workflow. `Err` is what is wrong with the file, for the person
305/// who wrote it; what reads but runs differently is in `notes`.
306pub fn parse(source: &str) -> Result<Workflow, String> {
307 let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
308 let raw = yaml_to_json(&yaml);
309 let Value::Object(root) = &raw else {
310 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
311 };
312 let mut notes = Vec::new();
313 let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
314
315 // `on`, in any of its three shapes. YAML 1.1 readers turn `on` into
316 // `true`; this reader keeps it, and accepts both.
317 let on = root.get("on").or_else(|| root.get("true")).ok_or("`on` is missing: say which events start the workflow.")?;
318 let mut triggers = Vec::new();
319 match on {
320 Value::String(event) => triggers.push(trigger(event, &Value::Null)),
321 Value::Array(events) => {
322 for event in events {
323 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
324 triggers.push(trigger(event, &Value::Null));
325 }
326 }
327 Value::Object(events) => {
328 for (event, spec) in events {
329 triggers.push(trigger(event, spec));
330 }
331 }
332 _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
333 }
334 for trigger in &triggers {
335 if let Some((_, why)) = UNSENT_EVENTS.iter().find(|(event, _)| *event == trigger.event) {
336 note(Severity::Unsupported, None, (*why).to_owned());
337 } else if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
338 note(
339 Severity::Unsupported,
340 None,
341 format!("g1t has no `{}` event, so that trigger never starts it.", trigger.event),
342 );
343 }
344 if trigger.event == "pull_request_target" {
345 note(
346 Severity::Info,
347 None,
348 "`pull_request_target` runs in the base's context: the default branch's copy of this workflow, at the default branch's head, with the repository's secrets. It does not check out the pull request's changes; a step that does runs code anyone could have written, with those secrets.".to_owned(),
349 );
350 }
351 if trigger.event == "workflow_call" && triggers.len() == 1 {
352 note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
353 }
354 }
355
356 let env = match root.get("env") {
357 Some(Value::Object(env)) => env.clone(),
358 _ => Map::new(),
359 };
360 let concurrency = concurrency_of(root.get("concurrency"));
361 let permissions = match root.get("permissions") {
362 None => None,
363 Some(value) => {
364 let (permissions, unknown) = permissions::parse(value)?;
365 permission_notes(&unknown, None, &mut note);
366 Some(permissions)
367 }
368 };
369
370 let Some(Value::Object(job_specs)) = root.get("jobs") else {
371 return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
372 };
373 if job_specs.is_empty() {
374 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
375 }
376 let mut jobs = Vec::new();
377 for (id, spec) in job_specs {
378 let Value::Object(spec) = spec else {
379 return Err(format!("Job `{id}` is a mapping."));
380 };
381 let uses = text(spec.get("uses"));
382 let steps_raw = match spec.get("steps") {
383 Some(Value::Array(steps)) => steps.clone(),
384 None if uses.is_some() => Vec::new(),
385 None => return Err(format!("Job `{id}` has no `steps`.")),
386 Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
387 };
388 let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
389 let labels: Vec<String> =
390 texts(Some(&runs_on)).into_iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default()).collect();
391 // `self-hosted`, or a runner group, sends the job to the workspace's
392 // own runners, which may be Linux, macOS or Windows.
393 let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted"));
394 let mut steps = Vec::new();
395 for (index, step) in steps_raw.iter().enumerate() {
396 let Value::Object(fields) = step else {
397 return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
398 };
399 let step = Step {
400 id: text(fields.get("id")),
401 name: text(fields.get("name")),
402 condition: text(fields.get("if")),
403 uses: text(fields.get("uses")),
404 run: text(fields.get("run")),
405 raw: step.clone(),
406 };
407 match (&step.uses, &step.run) {
408 (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
409 (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
410 _ => {}
411 }
412 if let Some(uses) = &step.uses
413 && let Some((severity, message)) = action_note(uses, fields.get("with").and_then(|with| with.get("cache")).is_some())
414 {
415 note(severity, Some(id), message);
416 }
417 if let Some(shell) = text(fields.get("shell"))
418 && !self_hosted
419 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
420 {
421 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
422 }
423 steps.push(step);
424 }
425 if self_hosted {
426 note(
427 Severity::Info,
428 Some(id),
429 "`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(),
430 );
431 } else {
432 for label in &labels {
433 let lower = label.to_ascii_lowercase();
434 if lower.contains("windows") || lower.contains("macos") {
435 note(
436 Severity::Unsupported,
437 Some(id),
438 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, ...]`."),
439 );
440 }
441 }
442 }
443 if spec.contains_key("services") {
444 note(
445 Severity::Info,
446 Some(id),
447 "`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(),
448 );
449 }
450 if spec.contains_key("container") {
451 note(
452 Severity::Info,
453 Some(id),
454 "`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(),
455 );
456 }
457 if spec.contains_key("environment") {
458 note(Severity::Info, Some(id), "`environment`: the job gets the values its secrets and variables give this environment once the environment's protection rules (required reviewers, a wait timer, which branches may deploy) let it through. Unless it says `deployment: false`, the run records a deployment to it.".to_owned());
459 }
460 let job_permissions = match spec.get("permissions") {
461 None => None,
462 Some(value) => {
463 let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?;
464 permission_notes(&unknown, Some(id), &mut note);
465 Some(permissions)
466 }
467 };
468 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
469 Some(Value::Object(strategy)) => (
470 strategy.get("matrix").cloned(),
471 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
472 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
473 ),
474 _ => (None, true, None),
475 };
476 if let Some(called) = uses.as_deref().filter(|uses| !uses.starts_with("./")) {
477 note(
478 Severity::Info,
479 Some(id),
480 format!(
481 "`{called}` is read from that repository on g1t when it is there and this repository may use it (a private one allows it under Settings, Actions, Access), and otherwise from a public repository on GitHub."
482 ),
483 );
484 }
485 jobs.push(Job {
486 id: id.clone(),
487 name: text(spec.get("name")),
488 needs: texts(spec.get("needs")),
489 condition: text(spec.get("if")),
490 runs_on,
491 matrix,
492 fail_fast,
493 max_parallel,
494 uses,
495 permissions: job_permissions,
496 concurrency: concurrency_of(spec.get("concurrency")),
497 steps,
498 raw: Value::Object(spec.clone()),
499 });
500 }
501 for job in &jobs {
502 for need in &job.needs {
503 if !jobs.iter().any(|other| &other.id == need) {
504 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
505 }
506 }
507 }
508 let workflow = Workflow {
509 name: text(root.get("name")),
510 run_name: text(root.get("run-name")),
511 triggers,
512 env,
513 concurrency,
514 permissions,
515 jobs,
516 notes,
517 raw,
518 };
519 if workflow.job_order().len() < workflow.jobs.len() {
520 return Err("The jobs' `needs` go round in a circle.".to_owned());
521 }
522 Ok(workflow)
523}
524
525/// `concurrency`, as a group's name or a mapping with `group` and
526/// `cancel-in-progress`.
527fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> {
528 match value {
529 Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
530 Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
531 group,
532 cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
533 }),
534 _ => None,
535 }
536}
537
538/// What to say about `permissions` names the token does not have.
539fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
540 for name in unknown {
541 note(Severity::Warning, job, format!("`permissions.{name}`: the token has no permission called that, so it grants nothing."));
542 }
543}
544
545/// What to say about an action g1t runs differently, if anything.
546/// `caches`: the step sets a `cache` input.
547fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> {
548 if uses.starts_with("docker://") {
549 return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine).")));
550 }
551 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
552 match name.as_str() {
553 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
554 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
555 Severity::Info,
556 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."),
557 )),
558 "actions/upload-artifact" | "actions/download-artifact" => Some((
559 Severity::Info,
560 format!("`{name}`: g1t keeps artifacts with the run for 14 days, up to 60 MB each."),
561 )),
562 _ if caches && name.starts_with("actions/setup-") => Some((
563 Severity::Warning,
564 format!("`{name}` with `cache:` runs without that cache on g1t. Add an `actions/cache` step for the same effect."),
565 )),
566 _ => None,
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 const CI: &str = r#"
575name: CI
576on:
577 push:
578 branches: [main]
579 paths-ignore: ["docs/**"]
580 pull_request:
581 workflow_dispatch:
582 inputs:
583 debug:
584 type: boolean
585 default: false
586 schedule:
587 - cron: "0 3 * * *"
588concurrency:
589 group: ci-${{ github.ref }}
590 cancel-in-progress: true
591env:
592 CARGO_TERM_COLOR: always
593jobs:
594 test:
595 runs-on: ${{ matrix.os }}
596 strategy:
597 matrix:
598 os: [ubuntu-latest, windows-latest]
599 node: [22, 24]
600 steps:
601 - uses: actions/checkout@v7
602 - uses: actions/setup-node@v7
603 with:
604 node-version: ${{ matrix.node }}
605 - run: npm ci
606 - name: Test
607 run: npm test
608 deploy:
609 needs: test
610 if: github.ref == 'refs/heads/main'
611 runs-on: ubuntu-latest
612 steps:
613 - run: echo deploy
614"#;
615
616 #[test]
617 fn a_whole_workflow_reads() {
618 let workflow = parse(CI).unwrap();
619 assert_eq!(workflow.name.as_deref(), Some("CI"));
620 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
621 let push = workflow.trigger("push").unwrap();
622 assert!(push.branches.allows("main"));
623 assert!(!push.branches.allows("dev"));
624 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
625 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
626 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
627 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
628 assert_eq!(workflow.jobs.len(), 2);
629 assert_eq!(workflow.jobs[1].needs, ["test"]);
630 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7");
631 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
632 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
633 assert_eq!(workflow.job_order(), ["test", "deploy"]);
634 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
635 }
636
637 #[test]
638 fn short_forms_of_on() {
639 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
640 assert_eq!(one.triggers[0].event, "push");
641 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
642 assert_eq!(list.triggers.len(), 2);
643 let pr = list.trigger("pull_request").unwrap();
644 assert!(pr.wants_type(Some("opened")));
645 assert!(pr.wants_type(Some("synchronize")));
646 assert!(!pr.wants_type(Some("closed")));
647 assert!(pr.wants_type(Some("ready_for_review")));
648 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
649 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
650 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
651 }
652
653 #[test]
654 fn notes_say_what_runs_differently() {
655 let workflow = parse(
656 "on: [push, watch]\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",
657 )
658 .unwrap();
659 let unsupported: Vec<&str> =
660 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
661 assert!(unsupported.iter().any(|m| m.contains("`watch`")));
662 let released = parse("on:\n release:\n types: [published]\n deployment_status:\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
663 assert!(!released.notes.iter().any(|n| n.severity == Severity::Unsupported));
664 assert!(released.trigger("release").unwrap().wants_type(Some("published")));
665 assert!(!released.trigger("release").unwrap().wants_type(Some("created")));
666 assert!(released.trigger("deployment_status").unwrap().wants_type(Some("created")));
667 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
668 assert!(!unsupported.iter().any(|m| m.contains("services")));
669 assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
670 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost")));
671 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
672 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
673 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
674 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node")));
675 assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry")));
676 }
677
678 #[test]
679 fn self_hosted_jobs_may_run_on_any_os() {
680 let workflow = parse(
681 "on: push
682jobs:
683 win:
684 runs-on: [self-hosted, windows]
685 steps:
686 - run: dir
687 shell: pwsh
688 mac:
689 runs-on: { group: Macs, labels: [macos] }
690 steps: [{ run: 'true' }]",
691 )
692 .unwrap();
693 assert!(!workflow.notes.iter().any(|n| n.severity == Severity::Unsupported), "{:?}", workflow.notes);
694 let routed: Vec<&str> = workflow.notes.iter().filter(|n| n.message.starts_with("`self-hosted`")).map(|n| n.message.as_str()).collect();
695 assert_eq!(routed.len(), 2);
696 assert!(routed.iter().all(|m| m.contains("self-hosted runners") && !m.contains("Linux")));
697 }
698
699 #[test]
700 fn permissions_are_read_at_both_levels() {
701 use crate::permissions::{Access, TokenDefault};
702 let workflow = parse(
703 "on: push
704permissions:
705 contents: read
706 pull-requests: write
707jobs:
708 plain:
709 runs-on: ubuntu-latest
710 steps: [{ run: 'true' }]
711 release:
712 runs-on: ubuntu-latest
713 permissions:
714 contents: write
715 steps: [{ run: 'true' }]
716 quiet:
717 runs-on: ubuntu-latest
718 permissions: {}
719 steps: [{ run: 'true' }]",
720 )
721 .unwrap();
722 let plain = workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted);
723 assert_eq!(plain.get("pull-requests"), Access::Write);
724 assert_eq!(plain.get("contents"), Access::Read);
725 // A job's own permissions replace the workflow's whole.
726 let release = workflow.jobs[1].permissions(&workflow, TokenDefault::Permissive);
727 assert_eq!(release.get("contents"), Access::Write);
728 assert_eq!(release.get("pull-requests"), Access::None);
729 assert_eq!(workflow.jobs[2].permissions(&workflow, TokenDefault::Permissive).scopes(), ["repo:read"]);
730 // Without any, the repository's default.
731 let bare = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
732 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("contents"), Access::Read);
733 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None);
734 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write);
735 // What reads but grants nothing is said.
736 let odd = parse("on: push\npermissions: { id-token: write, wiki: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
737 assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued");
738 assert!(odd.notes.iter().any(|n| n.message.contains("`permissions.wiki`")));
739 assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all"));
740 assert!(
741 parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]")
742 .unwrap_err()
743 .contains("Job `a`")
744 );
745 }
746
747 #[test]
748 fn a_job_has_its_own_concurrency() {
749 let workflow = parse(
750 "on: push
751jobs:
752 deploy:
753 runs-on: ubuntu-latest
754 concurrency:
755 group: deploy-${{ github.ref }}
756 cancel-in-progress: true
757 steps: [{ run: 'true' }]
758 named:
759 runs-on: ubuntu-latest
760 concurrency: just-one
761 steps: [{ run: 'true' }]",
762 )
763 .unwrap();
764 let deploy = workflow.jobs[0].concurrency.as_ref().unwrap();
765 assert_eq!(deploy.group, "deploy-${{ github.ref }}");
766 assert_eq!(deploy.cancel_in_progress, Value::Bool(true));
767 assert_eq!(workflow.jobs[1].concurrency.as_ref().unwrap().group, "just-one");
768 assert!(workflow.concurrency.is_none());
769 }
770
771 #[test]
772 fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() {
773 let workflow = parse("on: [create, delete, label, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
774 let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
775 assert_eq!(unsupported.len(), 1, "{unsupported:?}");
776 assert!(unsupported[0].contains("`label`"));
777 let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap();
778 assert!(target.message.contains("default branch"));
779 assert!(!target.message.contains("on the pull request's head"));
780 }
781
782 #[test]
783 fn mistakes_are_explained() {
784 let problem = |yaml: &str| parse(yaml).unwrap_err();
785 assert!(problem("jobs: {}").contains("`on` is missing"));
786 assert!(problem("on: push").contains("`jobs` is missing"));
787 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
788 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
789 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
790 assert!(
791 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 }]")
792 .contains("circle")
793 );
794 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
795 assert!(problem(": : :").contains("not valid YAML"));
796 }
797}