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