Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,233 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};
10use crate::syntax;
11
12/// Where workflows live: GitHub's `.github/workflows`, under g1t's own
13/// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
14/// `.github` stays GitHub's.
15pub const FOLDER: &str = ".g1t/workflows";
16
17/// The events a workflow can name that g1t starts runs for.
18pub const SUPPORTED_EVENTS: &[&str] = &[
19 "push",
20 "pull_request",
21 "pull_request_target",
22 "pull_request_review",
23 "work_item",
24 "work_item_comment",
25 "schedule",
26 "workflow_dispatch",
27 "repository_dispatch",
28 "workflow_call",
29 "workflow_run",
30 "merge_group",
31 "create",
32 "delete",
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 (
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 a work item or a pull request starts `work_item` and `pull_request` workflows with the `labeled` type.",
44 ),
45 (
46 "issues",
47 "g1t has work items, not issues, so the `issues` trigger never starts it. Write `work_item`: it has the same activity types, and `github.event.work_item` in place of `github.event.issue`.",
48 ),
49 (
50 "issue_comment",
51 "g1t has work items, not issues, so the `issue_comment` trigger never starts it. Write `work_item_comment`: it runs for comments on work items and pull requests, with `github.event.work_item` in place of `github.event.issue`.",
52 ),
53];
54
55/// The `types` each event has when a workflow gives none, as on GitHub.
56pub fn default_types(event: &str) -> &'static [&'static str] {
57 match event {
58 "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"],
59 "merge_group" => &["checks_requested"],
60 _ => &[],
61 }
62}
63
64/// How much a note matters.
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum Severity {
68 /// Runs, slightly differently.
69 Info,
70 /// Runs, but something in it does nothing or may not work.
71 Warning,
72 /// Does not run on g1t.
73 Unsupported,
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Note {
78 pub severity: Severity,
79 /// The job, if the note is about one.
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub job: Option<String>,
82 pub message: String,
83}
84
85/// One event a workflow is started by, with its filters.
86#[derive(Clone, Debug, Default, PartialEq, Eq)]
87pub struct Trigger {
88 pub event: String,
89 /// Activity types; empty means the event's defaults (or all).
90 pub types: Vec<String>,
91 pub branches: Filter,
92 pub tags: Filter,
93 pub paths: Filter,
94 /// For `schedule`.
95 pub crons: Vec<String>,
96 /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
97 pub inputs: Map<String, Value>,
98 /// For `workflow_run`: the names of the workflows it follows.
99 pub workflows: Vec<String>,
100}
101
102impl Trigger {
103 /// Whether an activity type starts it.
104 pub fn wants_type(&self, action: Option<&str>) -> bool {
105 let Some(action) = action else { return true };
106 if self.types.is_empty() {
107 // A g1t agent's pull request has no code until it is marked
108 // ready, so that is when its default runs start, as `opened`
109 // would on GitHub.
110 if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" {
111 return true;
112 }
113 let defaults = default_types(&self.event);
114 return defaults.is_empty() || defaults.contains(&action);
115 }
116 self.types.iter().any(|t| t == action)
117 }
118}
119
120#[derive(Clone, Debug, PartialEq)]
121pub struct Step {
122 pub id: Option<String>,
123 pub name: Option<String>,
124 pub condition: Option<String>,
125 pub uses: Option<String>,
126 pub run: Option<String>,
127 /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys.
128 pub g1t: Option<Value>,
129 /// The whole step as written, for the sandbox.
130 pub raw: Value,
131}
132
133impl Step {
134 /// How the step is shown when it has no name.
135 pub fn title(&self) -> String {
136 if let Some(name) = &self.name {
137 return name.clone();
138 }
139 if let Some(uses) = &self.uses {
140 return format!("Run {uses}");
141 }
142 let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
143 format!("Run {}", first.trim())
144 }
145}
146
147#[derive(Clone, Debug, PartialEq)]
148pub struct Job {
149 /// Its key under `jobs:`.
150 pub id: String,
151 pub name: Option<String>,
152 pub needs: Vec<String>,
153 pub condition: Option<String>,
154 pub runs_on: Value,
155 /// `strategy.matrix`, as written (it may be an expression).
156 pub matrix: Option<Value>,
157 pub fail_fast: bool,
158 pub max_parallel: Option<u32>,
159 /// A reusable workflow it calls (`uses:` on a job).
160 pub uses: Option<String>,
161 /// Its own `permissions`, which replace the workflow's.
162 pub permissions: Option<Permissions>,
163 /// Its own `concurrency`: at most one job of its group runs at a time.
164 pub concurrency: Option<Concurrency>,
165 pub steps: Vec<Step>,
166 /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys.
167 pub g1t: Option<Value>,
168 /// The whole job as written, for the sandbox.
169 pub raw: Value,
170}
171
172impl Job {
173 /// What its token may do: its own `permissions`, else its workflow's,
174 /// else `default` (the repository's choice).
175 pub fn permissions(&self, workflow: &Workflow, default: permissions::TokenDefault) -> Permissions {
176 self.permissions
177 .clone()
178 .or_else(|| workflow.permissions.clone())
179 .unwrap_or_else(|| Permissions::default_for(default))
180 }
181}
182
183#[derive(Clone, Debug, PartialEq)]
184pub struct Workflow {
185 pub name: Option<String>,
186 pub run_name: Option<String>,
187 pub triggers: Vec<Trigger>,
188 pub env: Map<String, Value>,
189 pub concurrency: Option<Concurrency>,
190 /// Its top-level `permissions`, for every job that writes none.
191 pub permissions: Option<Permissions>,
192 pub jobs: Vec<Job>,
193 pub notes: Vec<Note>,
194 /// Its top-level `g1t` settings, as written: g1t's own, beside
195 /// GitHub's keys.
196 pub g1t: Option<Value>,
197 /// The whole workflow as written.
198 pub raw: Value,
199}
200
201#[derive(Clone, Debug, PartialEq, Eq)]
202pub struct Concurrency {
203 /// May hold an expression.
204 pub group: String,
205 pub cancel_in_progress: Value,
206}
207
208impl Workflow {
209 pub fn trigger(&self, event: &str) -> Option<&Trigger> {
210 self.triggers.iter().find(|trigger| trigger.event == event)
211 }
212
213 /// The name shown for it: its `name`, or its file's path.
214 pub fn display_name(&self, path: &str) -> String {
215 self.name.clone().unwrap_or_else(|| path.to_owned())
216 }
217
218 /// The job ids in an order where each comes after the jobs it needs.
219 pub fn job_order(&self) -> Vec<&str> {
220 let mut ordered: Vec<&str> = Vec::new();
221 while ordered.len() < self.jobs.len() {
222 let before = ordered.len();
223 for job in &self.jobs {
224 if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
225 ordered.push(&job.id);
226 }
227 }
228 if ordered.len() == before {
229 break;
230 }
231 }
232 ordered
233 }
234}
235
236/// YAML to JSON, keeping the order of keys. Keys that are not strings
237/// (`on: true` in YAML 1.1, numbers) become their text.
238pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
239 match value {
240 serde_yaml::Value::Null => Value::Null,
241 serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
242 serde_yaml::Value::Number(number) => {
243 if let Some(n) = number.as_i64() {
244 Value::from(n)
245 } else if let Some(n) = number.as_u64() {
246 Value::from(n)
247 } else {
248 number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
249 }
250 }
251 serde_yaml::Value::String(text) => Value::String(text.clone()),
252 serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
253 serde_yaml::Value::Mapping(map) => {
254 let mut out = Map::new();
255 for (key, value) in map {
256 let key = match key {
257 serde_yaml::Value::String(text) => text.clone(),
258 serde_yaml::Value::Bool(flag) => flag.to_string(),
259 serde_yaml::Value::Number(number) => number.to_string(),
260 _ => continue,
261 };
262 out.insert(key, yaml_to_json(value));
263 }
264 Value::Object(out)
265 }
266 serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
267 }
268}
269
270fn texts(value: Option<&Value>) -> Vec<String> {
271 match value {
272 Some(Value::String(text)) => vec![text.clone()],
273 Some(Value::Array(items)) => items
274 .iter()
275 .filter_map(|item| match item {
276 Value::String(text) => Some(text.clone()),
277 Value::Number(n) => Some(n.to_string()),
278 _ => None,
279 })
280 .collect(),
281 _ => Vec::new(),
282 }
283}
284
285fn text(value: Option<&Value>) -> Option<String> {
286 match value? {
287 Value::String(text) => Some(text.clone()),
288 Value::Number(n) => Some(n.to_string()),
289 Value::Bool(flag) => Some(flag.to_string()),
290 _ => None,
291 }
292}
293
294fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
295 let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
296 Filter { only: list(only), ignore: list(ignore) }
297}
298
299fn trigger(event: &str, spec: &Value) -> Trigger {
300 let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
301 match spec {
302 Value::Object(spec) => {
303 trigger.types = texts(spec.get("types"));
304 trigger.branches = filter(spec, "branches", "branches-ignore");
305 trigger.tags = filter(spec, "tags", "tags-ignore");
306 trigger.paths = filter(spec, "paths", "paths-ignore");
307 if let Some(Value::Object(inputs)) = spec.get("inputs") {
308 trigger.inputs = inputs.clone();
309 }
310 trigger.workflows = texts(spec.get("workflows"));
311 }
312 Value::Array(entries) if event == "schedule" => {
313 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
314 }
315 _ => {}
316 }
317 trigger
318}
319
320/// Reads a workflow. `Err` is what is wrong with the file, for the person
321/// who wrote it; what reads but runs differently is in `notes`.
322///
323/// It accepts what GitHub accepts and refuses what GitHub refuses: every
324/// mapping has only the keys GitHub's schema gives it ([`syntax`]), plus
325/// `g1t`, g1t's own settings, on the workflow, a job and a step.
326pub fn parse(source: &str) -> Result<Workflow, String> {
327 let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
328 if has_merge_key(&yaml) {
329 return Err("It uses a YAML merge key (`<<`), which GitHub does not read, so g1t does not either. Write the keys out, or give a whole value an anchor (`&name`) and repeat it with `*name`.".to_owned());
330 }
331 let raw = yaml_to_json(&yaml);
332 let Value::Object(root) = &raw else {
333 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
334 };
335 let mut notes = Vec::new();
336 let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
337 check_keys(root, syntax::WORKFLOW, true, "", "the workflow")?;
338
339 // `on`, in any of its three shapes.
340 let on = root.get("on").ok_or("`on` is missing: say which events start the workflow.")?;
341 let known = |event: &str| syntax::event(event).ok_or_else(|| format!("`on`: GitHub has no `{event}` event, so neither does g1t."));
342 let mut triggers = Vec::new();
343 match on {
344 Value::String(event) => {
345 known(event)?;
346 triggers.push(trigger(event, &Value::Null));
347 }
348 Value::Array(events) => {
349 for event in events {
350 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
351 known(event)?;
352 triggers.push(trigger(event, &Value::Null));
353 }
354 }
355 Value::Object(events) => {
356 for (event, spec) in events {
357 check_event(event, known(event)?, spec, &mut note)?;
358 triggers.push(trigger(event, spec));
359 }
360 }
361 _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
362 }
363 for trigger in &triggers {
364 if let Some((_, why)) = UNSENT_EVENTS.iter().find(|(event, _)| *event == trigger.event) {
365 note(Severity::Unsupported, None, (*why).to_owned());
366 } else if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
367 note(
368 Severity::Unsupported,
369 None,
370 format!("g1t does not send GitHub's `{}` event, so that trigger never starts it.", trigger.event),
371 );
372 }
373 if trigger.event == "pull_request_target" {
374 note(
375 Severity::Info,
376 None,
377 "`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(),
378 );
379 }
380 if trigger.event == "workflow_call" && triggers.len() == 1 {
381 note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
382 }
383 }
384
385 let env = match root.get("env") {
386 Some(Value::Object(env)) => env.clone(),
387 _ => Map::new(),
388 };
389 if let Some(defaults) = root.get("defaults") {
390 check_defaults(defaults, "")?;
391 }
392 if let Some(concurrency) = root.get("concurrency") {
393 check_concurrency(concurrency, "", None, &mut note)?;
394 }
395 let concurrency = concurrency_of(root.get("concurrency"));
396 let permissions = match root.get("permissions") {
397 None => None,
398 Some(value) => {
399 if let Value::Object(map) = value {
400 check_keys(map, syntax::PERMISSIONS, false, "", "`permissions`")?;
401 }
402 let (permissions, unknown) = permissions::parse(value)?;
403 permission_notes(&unknown, None, &mut note);
404 Some(permissions)
405 }
406 };
407
408 let Some(Value::Object(job_specs)) = root.get("jobs") else {
409 return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
410 };
411 if job_specs.is_empty() {
412 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
413 }
414 let mut jobs = Vec::new();
415 let mut seen_ids: Vec<String> = Vec::new();
416 for (id, spec) in job_specs {
417 if !syntax::valid_id(id) {
418 return Err(format!("`{id}` is not a job id GitHub accepts: {ID_RULE}"));
419 }
420 let lower = id.to_ascii_lowercase();
421 if let Some(index) = seen_ids.iter().position(|seen| *seen == lower) {
422 let other = job_specs.keys().nth(index).cloned().unwrap_or_default();
423 return Err(format!("Job `{id}` is already defined as `{other}`: GitHub compares job ids without case."));
424 }
425 seen_ids.push(lower);
426 let Value::Object(spec) = spec else {
427 return Err(format!("Job `{id}` is a mapping."));
428 };
429 let place = format!("Job `{id}`");
430 let uses = text(spec.get("uses"));
431 if spec.contains_key("uses") {
432 for key in ["runs-on", "steps"] {
433 if spec.contains_key(key) {
434 return Err(format!("Job `{id}` calls a reusable workflow with `uses`, so it has no `{key}`: the called workflow's jobs say that."));
435 }
436 }
437 check_keys(spec, syntax::CALLER_JOB, true, &place, "a job that calls a reusable workflow")?;
438 if uses.as_deref().is_none_or(|uses| uses.trim().is_empty()) {
439 return Err(format!("Job `{id}`: `uses` names the reusable workflow it calls."));
440 }
441 match spec.get("secrets") {
442 None | Some(Value::Object(_)) => {}
443 Some(Value::String(inherit)) if inherit == "inherit" => {}
444 Some(_) => return Err(format!("Job `{id}`: `secrets` is a mapping of the secrets it passes, or `inherit`.")),
445 }
446 } else {
447 check_keys(spec, syntax::JOB, true, &place, "a job")?;
448 if !spec.contains_key("runs-on") {
449 return Err(format!("Job `{id}` has no `runs-on`: say which runner it runs on, such as `runs-on: ubuntu-latest`."));
450 }
451 }
452 check_job_parts(id, spec, &mut note)?;
453 let steps_raw = match spec.get("steps") {
454 Some(Value::Array(steps)) => steps.clone(),
455 None if uses.is_some() => Vec::new(),
456 None => return Err(format!("Job `{id}` has no `steps`.")),
457 Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
458 };
459 let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
460 let labels: Vec<String> =
461 texts(Some(&runs_on)).into_iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default()).collect();
462 // `self-hosted`, or a runner group, sends the job to the workspace's
463 // own runners, which may be Linux, macOS or Windows.
464 let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted"));
465 let mut steps = Vec::new();
466 let mut step_ids: Vec<String> = Vec::new();
467 for (index, step) in steps_raw.iter().enumerate() {
468 let place = format!("Job `{id}`, step {}", index + 1);
469 let Value::Object(fields) = step else {
470 return Err(format!("{place}: a step is a mapping."));
471 };
472 if let Some(key) = syntax::BACKGROUND_STEPS.iter().find(|key| fields.contains_key(**key)) {
473 return Err(format!(
474 "{place}: `{key}` is one of GitHub's background steps (`background`, `wait`, `wait-all`, `cancel`, `parallel`), which g1t does not run yet."
475 ));
476 }
477 let step = Step {
478 id: text(fields.get("id")),
479 name: text(fields.get("name")),
480 condition: text(fields.get("if")),
481 uses: text(fields.get("uses")),
482 run: text(fields.get("run")),
483 g1t: fields.get(syntax::EXTENSION).cloned(),
484 raw: step.clone(),
485 };
486 match (&step.uses, &step.run) {
487 (Some(_), Some(_)) => return Err(format!("{place}: a step has `uses` or `run`, not both.")),
488 (None, None) => return Err(format!("{place}: a step needs `uses` or `run`.")),
489 (Some(_), None) => check_keys(fields, syntax::ACTION_STEP, true, &place, "a step that uses an action")?,
490 (None, Some(_)) => check_keys(fields, syntax::RUN_STEP, true, &place, "a step that runs a command")?,
491 }
492 if let Some(step_id) = &step.id {
493 if !syntax::valid_id(step_id) {
494 return Err(format!("{place}: `{step_id}` is not a step id GitHub accepts: {ID_RULE}"));
495 }
496 let lower = step_id.to_ascii_lowercase();
497 if step_ids.contains(&lower) {
498 return Err(format!("{place}: another step of job `{id}` has the id `{step_id}` already (GitHub compares step ids without case)."));
499 }
500 step_ids.push(lower);
501 }
502 if let Some(uses) = &step.uses
503 && let Some((severity, message)) = action_note(uses)
504 {
505 note(severity, Some(id), message);
506 }
507 if let Some(shell) = text(fields.get("shell"))
508 && !self_hosted
509 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
510 {
511 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
512 }
513 steps.push(step);
514 }
515 if self_hosted {
516 note(
517 Severity::Info,
518 Some(id),
519 "`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(),
520 );
521 } else {
522 for label in &labels {
523 let lower = label.to_ascii_lowercase();
524 if lower.contains("windows") || lower.contains("macos") {
525 note(
526 Severity::Unsupported,
527 Some(id),
528 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, ...]`."),
529 );
530 } else if lower.contains("arm") {
531 note(
532 Severity::Warning,
533 Some(id),
534 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]`."),
535 );
536 }
537 }
538 if let Some(minutes) = spec.get("timeout-minutes").and_then(Value::as_f64)
539 && minutes > f64::from(HOSTED_MAX_TIMEOUT_MINUTES)
540 {
541 note(
542 Severity::Warning,
543 Some(id),
544 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."),
545 );
546 }
547 }
548 if spec.contains_key("services") {
549 note(
550 Severity::Info,
551 Some(id),
552 "`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(),
553 );
554 }
555 if spec.contains_key("container") {
556 note(
557 Severity::Info,
558 Some(id),
559 "`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(),
560 );
561 }
562 if spec.contains_key("environment") {
563 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());
564 }
565 let job_permissions = match spec.get("permissions") {
566 None => None,
567 Some(value) => {
568 if let Value::Object(map) = value {
569 check_keys(map, syntax::PERMISSIONS, false, &place, "`permissions`")?;
570 }
571 let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?;
572 permission_notes(&unknown, Some(id), &mut note);
573 Some(permissions)
574 }
575 };
576 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
577 Some(Value::Object(strategy)) => (
578 strategy.get("matrix").cloned(),
579 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
580 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
581 ),
582 _ => (None, true, None),
583 };
584 if let Some(called) = uses.as_deref().filter(|uses| !uses.starts_with("./")) {
585 note(
586 Severity::Info,
587 Some(id),
588 format!(
589 "`{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."
590 ),
591 );
592 }
593 jobs.push(Job {
594 id: id.clone(),
595 name: text(spec.get("name")),
596 needs: texts(spec.get("needs")),
597 condition: text(spec.get("if")),
598 runs_on,
599 matrix,
600 fail_fast,
601 max_parallel,
602 uses,
603 permissions: job_permissions,
604 concurrency: concurrency_of(spec.get("concurrency")),
605 steps,
606 g1t: spec.get(syntax::EXTENSION).cloned(),
607 raw: Value::Object(spec.clone()),
608 });
609 }
610 for job in &jobs {
611 for need in &job.needs {
612 if !jobs.iter().any(|other| &other.id == need) {
613 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
614 }
615 }
616 }
617 let workflow = Workflow {
618 name: text(root.get("name")),
619 run_name: text(root.get("run-name")),
620 triggers,
621 env,
622 concurrency,
623 permissions,
624 jobs,
625 notes,
626 g1t: root.get(syntax::EXTENSION).cloned(),
627 raw,
628 };
629 if workflow.job_order().len() < workflow.jobs.len() {
630 return Err("The jobs' `needs` go round in a circle.".to_owned());
631 }
632 Ok(workflow)
633}
634
635/// Whether a job (as written) records a deployment to its environment:
636/// unless it says `deployment: false`, in its `environment` as on GitHub,
637/// or in its `g1t` settings.
638pub fn records_deployment(job: &Value) -> bool {
639 let off = |value: Option<&Value>| match value {
640 Some(Value::Bool(flag)) => !flag,
641 Some(Value::String(text)) => text.trim() == "false",
642 _ => false,
643 };
644 !off(job.get("environment").and_then(|environment| environment.get("deployment")))
645 && !off(job.get(syntax::EXTENSION).and_then(|settings| settings.get("deployment")))
646}
647
648/// GitHub's rule for job and step ids, as said when one breaks it.
649const ID_RULE: &str = "it starts with a letter or `_`, has only letters, digits, `-` and `_`, is under 100 characters, and does not start with `__`.";
650
651/// Whether a YAML merge key (`<<`) is anywhere in it.
652fn has_merge_key(value: &serde_yaml::Value) -> bool {
653 match value {
654 serde_yaml::Value::Mapping(map) => map.iter().any(|(key, value)| key.as_str() == Some("<<") || has_merge_key(value)),
655 serde_yaml::Value::Sequence(items) => items.iter().any(has_merge_key),
656 serde_yaml::Value::Tagged(tagged) => has_merge_key(&tagged.value),
657 _ => false,
658 }
659}
660
661/// Keys as a sentence: `a`, `b` or `c`.
662fn listed(keys: &[&str]) -> String {
663 let quoted: Vec<String> = keys.iter().map(|key| format!("`{key}`")).collect();
664 match quoted.split_last() {
665 None => String::new(),
666 Some((last, [])) => last.clone(),
667 Some((last, rest)) => format!("{} or {last}", rest.join(", ")),
668 }
669}
670
671/// `message`, said of `place` ("Job `build`, step 2"), or on its own for
672/// the workflow's top level.
673fn at(place: &str, message: &str) -> String {
674 if place.is_empty() {
675 let mut chars = message.chars();
676 chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
677 } else {
678 format!("{place}: {message}")
679 }
680}
681
682/// Refuses any key of `map` that is not `allowed`, as GitHub does
683/// ("Unexpected value"). With `extension`, `g1t` is allowed too, as a
684/// mapping of anything. `what` names the mapping in the message.
685fn check_keys(map: &Map<String, Value>, allowed: &[&str], extension: bool, place: &str, what: &str) -> Result<(), String> {
686 for (key, value) in map {
687 if extension && key == syntax::EXTENSION {
688 if !value.is_object() {
689 return Err(at(place, "`g1t` is a mapping of g1t's own settings."));
690 }
691 continue;
692 }
693 if !allowed.contains(&key.as_str()) {
694 return Err(at(place, &format!("unexpected `{key}` in {what}; it can have {}.", listed(allowed))));
695 }
696 }
697 Ok(())
698}
699
700/// Refuses a mapping without `key`.
701fn require(map: &Map<String, Value>, key: &str, place: &str, what: &str) -> Result<(), String> {
702 if map.contains_key(key) { Ok(()) } else { Err(at(place, &format!("{what} needs `{key}`."))) }
703}
704
705/// One event under `on` and what it is given.
706fn check_event(event: &str, kind: syntax::EventSpec, spec: &Value, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
707 let what = format!("`on.{event}`");
708 match (kind, spec) {
709 (syntax::EventSpec::Schedule, Value::Array(entries)) => {
710 for entry in entries {
711 let Value::Object(entry) = entry else { return Err("`on.schedule` is a list of `cron:` entries.".to_owned()) };
712 check_keys(entry, syntax::CRON, false, "", "an entry of `on.schedule`")?;
713 require(entry, "cron", "", "Each entry of `on.schedule`")?;
714 if entry.contains_key("timezone") {
715 note(
716 Severity::Warning,
717 None,
718 "`on.schedule`: g1t reads every `cron` in UTC and does not read `timezone` yet, so the schedule runs at those times in UTC.".to_owned(),
719 );
720 }
721 }
722 Ok(())
723 }
724 (syntax::EventSpec::Schedule, _) => Err("`on.schedule` is a list of `cron:` entries.".to_owned()),
725 (_, Value::Null) => Ok(()),
726 (syntax::EventSpec::Bare, _) => Err(format!("{what} takes no settings: write `{event}:` with nothing after it.")),
727 (syntax::EventSpec::Filters(keys), Value::Object(map)) => {
728 check_keys(map, keys, false, "", &what)?;
729 for only in ["branches", "tags", "paths"] {
730 let ignore = format!("{only}-ignore");
731 if map.contains_key(only) && map.contains_key(&ignore) {
732 return Err(format!(
733 "{what} has both `{only}` and `{ignore}`: GitHub takes one or the other for an event. To leave some out of `{only}`, start a pattern with `!`."
734 ));
735 }
736 }
737 let definitions = |key: &str| -> Result<Vec<(&String, &Value)>, String> {
738 match map.get(key) {
739 None | Some(Value::Null) => Ok(Vec::new()),
740 Some(Value::Object(entries)) => Ok(entries.iter().collect()),
741 Some(_) => Err(format!("`on.{event}.{key}` is a mapping of names to their definitions.")),
742 }
743 };
744 if event == "workflow_dispatch" {
745 for (name, input) in definitions("inputs")? {
746 match input {
747 Value::Null => {}
748 Value::Object(input) => check_keys(input, syntax::DISPATCH_INPUT, false, "", &format!("input `{name}` of {what}"))?,
749 _ => return Err(format!("Input `{name}` of {what} is a mapping.")),
750 }
751 }
752 }
753 if event == "workflow_call" {
754 for (name, input) in definitions("inputs")? {
755 let Value::Object(input) = input else { return Err(format!("Input `{name}` of {what} is a mapping with its `type`.")) };
756 let place = format!("input `{name}` of {what}");
757 check_keys(input, syntax::CALL_INPUT, false, "", &place)?;
758 require(input, "type", "", &format!("Input `{name}` of {what}"))?;
759 }
760 for (name, output) in definitions("outputs")? {
761 let Value::Object(output) = output else { return Err(format!("Output `{name}` of {what} is a mapping with its `value`.")) };
762 check_keys(output, syntax::CALL_OUTPUT, false, "", &format!("output `{name}` of {what}"))?;
763 require(output, "value", "", &format!("Output `{name}` of {what}"))?;
764 }
765 for (name, secret) in definitions("secrets")? {
766 match secret {
767 Value::Null => {}
768 Value::Object(secret) => check_keys(secret, syntax::CALL_SECRET, false, "", &format!("secret `{name}` of {what}"))?,
769 _ => return Err(format!("Secret `{name}` of {what} is a mapping, or nothing.")),
770 }
771 }
772 }
773 Ok(())
774 }
775 (syntax::EventSpec::Filters(_), _) => Err(format!("{what} is a mapping of its filters, or nothing.")),
776 }
777}
778
779/// `defaults`, at the top level or on a job.
780fn check_defaults(value: &Value, place: &str) -> Result<(), String> {
781 let Value::Object(defaults) = value else { return Err(at(place, "`defaults` is a mapping with `run`.")) };
782 check_keys(defaults, syntax::DEFAULTS, false, place, "`defaults`")?;
783 match defaults.get("run") {
784 None | Some(Value::Null) => Ok(()),
785 Some(Value::Object(run)) => check_keys(run, syntax::DEFAULTS_RUN, false, place, "`defaults.run`"),
786 Some(_) => Err(at(place, "`defaults.run` is a mapping with `shell` or `working-directory`.")),
787 }
788}
789
790/// `concurrency`, at the top level or on a job (`job`).
791fn check_concurrency(value: &Value, place: &str, job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
792 let Value::Object(concurrency) = value else { return Ok(()) };
793 check_keys(concurrency, syntax::CONCURRENCY, false, place, "`concurrency`")?;
794 require(concurrency, "group", place, "`concurrency`")?;
795 if let Some(queue) = concurrency.get("queue")
796 && text(Some(queue)).as_deref() != Some("single")
797 {
798 note(
799 Severity::Warning,
800 job,
801 "`concurrency.queue`: g1t keeps one run or job waiting in a group, as `queue: single` does, and does not read `queue` yet.".to_owned(),
802 );
803 }
804 Ok(())
805}
806
807/// A job's mappings other than its steps: each has only GitHub's keys.
808fn check_job_parts(id: &str, spec: &Map<String, Value>, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
809 let place = format!("Job `{id}`");
810 let credentials = |container: &Map<String, Value>, what: &str| -> Result<(), String> {
811 match container.get("credentials") {
812 Some(Value::Object(credentials)) => check_keys(credentials, syntax::CREDENTIALS, false, &place, &format!("the `credentials` of {what}")),
813 _ => Ok(()),
814 }
815 };
816 if let Some(Value::Object(runs_on)) = spec.get("runs-on") {
817 check_keys(runs_on, syntax::RUNS_ON, false, &place, "`runs-on`")?;
818 }
819 if let Some(Value::Object(strategy)) = spec.get("strategy") {
820 check_keys(strategy, syntax::STRATEGY, false, &place, "`strategy`")?;
821 }
822 if let Some(Value::Object(container)) = spec.get("container") {
823 check_keys(container, syntax::CONTAINER, false, &place, "`container`")?;
824 credentials(container, "`container`")?;
825 }
826 if let Some(Value::Object(services)) = spec.get("services") {
827 for (name, service) in services {
828 if let Value::Object(service) = service {
829 let what = format!("service `{name}`");
830 check_keys(service, syntax::SERVICE, false, &place, &what)?;
831 credentials(service, &what)?;
832 }
833 }
834 }
835 if let Some(Value::Object(environment)) = spec.get("environment") {
836 check_keys(environment, syntax::ENVIRONMENT, false, &place, "`environment`")?;
837 require(environment, "name", &place, "`environment`")?;
838 }
839 if let Some(concurrency) = spec.get("concurrency") {
840 check_concurrency(concurrency, &place, Some(id), note)?;
841 }
842 if let Some(defaults) = spec.get("defaults") {
843 check_defaults(defaults, &place)?;
844 }
845 if spec.contains_key("snapshot") {
846 note(Severity::Unsupported, Some(id), "`snapshot`: g1t does not make custom runner images from a job yet, so the job runs and no image is made.".to_owned());
847 }
848 if spec.contains_key("cancel-timeout-minutes") {
849 note(Severity::Warning, Some(id), "`cancel-timeout-minutes`: g1t does not read it yet, so a cancelled job stops in its usual time.".to_owned());
850 }
851 Ok(())
852}
853
854/// `concurrency`, as a group's name or a mapping with `group` and
855/// `cancel-in-progress`.
856fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> {
857 match value {
858 Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
859 Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
860 group,
861 cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
862 }),
863 _ => None,
864 }
865}
866
867/// The longest a job on g1t's own machines may run, whatever its
868/// `timeout-minutes`.
869pub const HOSTED_MAX_TIMEOUT_MINUTES: u32 = 60;
870/// How long a job may run when it names no `timeout-minutes`, as on GitHub.
871pub const DEFAULT_TIMEOUT_MINUTES: u32 = 360;
872
873/// What to say about `permissions` names GitHub has that g1t's token has
874/// nothing behind.
875fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
876 for name in unknown {
877 let message = if name == "issues" {
878 "`permissions.issues`: g1t has work items, not issues, so it grants nothing. Write `work` for the token to read or change work items.".to_owned()
879 } else {
880 format!("`permissions.{name}`: g1t's token has nothing behind that permission yet, so it grants nothing.")
881 };
882 note(Severity::Warning, job, message);
883 }
884}
885
886/// What to say about an action g1t runs differently, if anything.
887fn action_note(uses: &str) -> Option<(Severity, String)> {
888 if uses.starts_with("docker://") {
889 return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine).")));
890 }
891 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
892 match name.as_str() {
893 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
894 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
895 Severity::Info,
896 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."),
897 )),
898 "actions/upload-artifact" | "actions/download-artifact" => Some((
899 Severity::Info,
900 format!("`{name}`: g1t keeps artifacts with the run for 14 days unless the repository says otherwise, up to 5 GiB each."),
901 )),
902 _ => None,
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909
910 const CI: &str = r#"
911name: CI
912on:
913 push:
914 branches: [main]
915 paths-ignore: ["docs/**"]
916 pull_request:
917 workflow_dispatch:
918 inputs:
919 debug:
920 type: boolean
921 default: false
922 schedule:
923 - cron: "0 3 * * *"
924concurrency:
925 group: ci-${{ github.ref }}
926 cancel-in-progress: true
927env:
928 CARGO_TERM_COLOR: always
929jobs:
930 test:
931 runs-on: ${{ matrix.os }}
932 strategy:
933 matrix:
934 os: [ubuntu-latest, windows-latest]
935 node: [22, 24]
936 steps:
937 - uses: actions/checkout@v7
938 - uses: actions/setup-node@v7
939 with:
940 node-version: ${{ matrix.node }}
941 - run: npm ci
942 - name: Test
943 run: npm test
944 deploy:
945 needs: test
946 if: github.ref == 'refs/heads/main'
947 runs-on: ubuntu-latest
948 steps:
949 - run: echo deploy
950"#;
951
952 #[test]
953 fn a_whole_workflow_reads() {
954 let workflow = parse(CI).unwrap();
955 assert_eq!(workflow.name.as_deref(), Some("CI"));
956 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
957 let push = workflow.trigger("push").unwrap();
958 assert!(push.branches.allows("main"));
959 assert!(!push.branches.allows("dev"));
960 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
961 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
962 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
963 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
964 assert_eq!(workflow.jobs.len(), 2);
965 assert_eq!(workflow.jobs[1].needs, ["test"]);
966 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7");
967 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
968 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
969 assert_eq!(workflow.job_order(), ["test", "deploy"]);
970 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
971 }
972
973 #[test]
974 fn short_forms_of_on() {
975 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
976 assert_eq!(one.triggers[0].event, "push");
977 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
978 assert_eq!(list.triggers.len(), 2);
979 let pr = list.trigger("pull_request").unwrap();
980 assert!(pr.wants_type(Some("opened")));
981 assert!(pr.wants_type(Some("synchronize")));
982 assert!(!pr.wants_type(Some("closed")));
983 assert!(pr.wants_type(Some("ready_for_review")));
984 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
985 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
986 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
987 }
988
989 #[test]
990 fn notes_say_what_runs_differently() {
991 let workflow = parse(
992 "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",
993 )
994 .unwrap();
995 let unsupported: Vec<&str> =
996 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
997 assert!(unsupported.iter().any(|m| m.contains("`watch`")));
998 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();
999 assert!(!released.notes.iter().any(|n| n.severity == Severity::Unsupported));
1000 assert!(released.trigger("release").unwrap().wants_type(Some("published")));
1001 assert!(!released.trigger("release").unwrap().wants_type(Some("created")));
1002 assert!(released.trigger("deployment_status").unwrap().wants_type(Some("created")));
1003 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
1004 assert!(!unsupported.iter().any(|m| m.contains("services")));
1005 assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
1006 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost")));
1007 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
1008 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
1009 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
1010 // Toolkit caches work on g1t, so `setup-node` with `cache:` says nothing.
1011 assert!(!workflow.notes.iter().any(|n| n.message.contains("actions/setup-node")));
1012 assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry")));
1013 }
1014
1015 #[test]
1016 fn notes_say_what_g1ts_machines_change_about_a_job() {
1017 let workflow = parse(
1018 "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' }]",
1019 )
1020 .unwrap();
1021 let warned = |job: &str, text: &str| {
1022 workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.job.as_deref() == Some(job) && n.message.contains(text))
1023 };
1024 assert!(warned("arm", "runs on x64"));
1025 assert!(warned("arm", "stopped at 60 minutes"));
1026 assert!(!workflow.notes.iter().any(|n| n.job.as_deref() == Some("call") && n.severity == Severity::Warning));
1027 assert!(!workflow.notes.iter().any(|n| matches!(n.job.as_deref(), Some("short" | "mine")) && n.severity == Severity::Warning));
1028 let artifacts = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ uses: actions/upload-artifact@v4 }]").unwrap();
1029 assert!(artifacts.notes.iter().any(|n| n.message.contains("5 GiB each")));
1030 }
1031
1032 #[test]
1033 fn self_hosted_jobs_may_run_on_any_os() {
1034 let workflow = parse(
1035 "on: push
1036jobs:
1037 win:
1038 runs-on: [self-hosted, windows]
1039 steps:
1040 - run: dir
1041 shell: pwsh
1042 mac:
1043 runs-on: { group: Macs, labels: [macos] }
1044 steps: [{ run: 'true' }]",
1045 )
1046 .unwrap();
1047 assert!(!workflow.notes.iter().any(|n| n.severity == Severity::Unsupported), "{:?}", workflow.notes);
1048 let routed: Vec<&str> = workflow.notes.iter().filter(|n| n.message.starts_with("`self-hosted`")).map(|n| n.message.as_str()).collect();
1049 assert_eq!(routed.len(), 2);
1050 assert!(routed.iter().all(|m| m.contains("self-hosted runners") && !m.contains("Linux")));
1051 }
1052
1053 #[test]
1054 fn permissions_are_read_at_both_levels() {
1055 use crate::permissions::{Access, TokenDefault};
1056 let workflow = parse(
1057 "on: push
1058permissions:
1059 contents: read
1060 pull-requests: write
1061jobs:
1062 plain:
1063 runs-on: ubuntu-latest
1064 steps: [{ run: 'true' }]
1065 release:
1066 runs-on: ubuntu-latest
1067 permissions:
1068 contents: write
1069 steps: [{ run: 'true' }]
1070 quiet:
1071 runs-on: ubuntu-latest
1072 permissions: {}
1073 steps: [{ run: 'true' }]",
1074 )
1075 .unwrap();
1076 let plain = workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted);
1077 assert_eq!(plain.get("pull-requests"), Access::Write);
1078 assert_eq!(plain.get("contents"), Access::Read);
1079 // A job's own permissions replace the workflow's whole.
1080 let release = workflow.jobs[1].permissions(&workflow, TokenDefault::Permissive);
1081 assert_eq!(release.get("contents"), Access::Write);
1082 assert_eq!(release.get("pull-requests"), Access::None);
1083 assert_eq!(workflow.jobs[2].permissions(&workflow, TokenDefault::Permissive).scopes(), ["repo:read"]);
1084 // Without any, the repository's default.
1085 let bare = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
1086 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("contents"), Access::Read);
1087 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("work"), Access::None);
1088 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("work"), Access::Write);
1089 // What reads but grants nothing is said.
1090 let odd = parse("on: push\npermissions: { id-token: write, artifact-metadata: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1091 assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued");
1092 assert!(odd.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("`permissions.artifact-metadata`")));
1093 // A name GitHub's token does not have is refused, as GitHub does;
1094 // `metadata` is always `read` and cannot be written.
1095 for name in ["wiki", "metadata"] {
1096 let problem = parse(&format!("on: push\npermissions: {{ {name}: read }}\njobs:\n a:\n runs-on: x\n steps: [{{ run: 'true' }}]")).unwrap_err();
1097 assert!(problem.contains(&format!("Unexpected `{name}` in `permissions`")), "{problem}");
1098 }
1099 assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all"));
1100 assert!(
1101 parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]")
1102 .unwrap_err()
1103 .contains("Job `a`")
1104 );
1105 }
1106
1107 #[test]
1108 fn a_job_has_its_own_concurrency() {
1109 let workflow = parse(
1110 "on: push
1111jobs:
1112 deploy:
1113 runs-on: ubuntu-latest
1114 concurrency:
1115 group: deploy-${{ github.ref }}
1116 cancel-in-progress: true
1117 steps: [{ run: 'true' }]
1118 named:
1119 runs-on: ubuntu-latest
1120 concurrency: just-one
1121 steps: [{ run: 'true' }]",
1122 )
1123 .unwrap();
1124 let deploy = workflow.jobs[0].concurrency.as_ref().unwrap();
1125 assert_eq!(deploy.group, "deploy-${{ github.ref }}");
1126 assert_eq!(deploy.cancel_in_progress, Value::Bool(true));
1127 assert_eq!(workflow.jobs[1].concurrency.as_ref().unwrap().group, "just-one");
1128 assert!(workflow.concurrency.is_none());
1129 }
1130
1131 #[test]
1132 fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() {
1133 let workflow = parse("on: [create, delete, label, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1134 let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
1135 assert_eq!(unsupported.len(), 1, "{unsupported:?}");
1136 assert!(unsupported[0].contains("`label`"));
1137 let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap();
1138 assert!(target.message.contains("default branch"));
1139 assert!(!target.message.contains("on the pull request's head"));
1140 }
1141
1142 #[test]
1143 fn mistakes_are_explained() {
1144 let problem = |yaml: &str| parse(yaml).unwrap_err();
1145 assert!(problem("jobs: {}").contains("`on` is missing"));
1146 assert!(problem("on: push").contains("`jobs` is missing"));
1147 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
1148 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
1149 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
1150 assert!(
1151 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 }]")
1152 .contains("circle")
1153 );
1154 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
1155 assert!(problem(": : :").contains("not valid YAML"));
1156 }
1157
1158 #[test]
1159 fn only_githubs_keys_and_g1ts_own() {
1160 let problem = |yaml: &str| parse(yaml).unwrap_err();
1161 let job = "\njobs:\n build:\n runs-on: x\n steps: [{ run: 'true' }]";
1162 assert!(problem(&format!("on: push\nrun_name: x{job}")).starts_with("Unexpected `run_name` in the workflow; it can have `on`, "));
1163 assert!(problem("on: push\njobs:\n build:\n runs-on: x\n step: []").starts_with("Job `build`: unexpected `step` in a job;"));
1164 let step = problem("on: push\njobs:\n build:\n runs-on: x\n steps:\n - run: 'true'\n - uses: actions/checkout@v5\n shell: bash");
1165 assert!(step.starts_with("Job `build`, step 2: unexpected `shell` in a step that uses an action;"), "{step}");
1166 assert!(problem("on: push\njobs:\n build:\n steps: [{ run: 'true' }]").contains("no `runs-on`"));
1167 assert!(problem("on: push\njobs:\n build:\n uses: ./.g1t/workflows/b.yml\n runs-on: x").contains("no `runs-on`"));
1168 assert!(problem("on: push\njobs:\n my.job:\n runs-on: x\n steps: [{ run: 'true' }]").contains("`my.job` is not a job id"));
1169 assert!(problem("on: pushh\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").contains("no `pushh` event"));
1170
1171 // `g1t` is g1t's own, at each level, and kept as written.
1172 let workflow = parse(
1173 "on: push
1174g1t: { note: top }
1175jobs:
1176 deploy:
1177 runs-on: x
1178 environment: production
1179 g1t: { deployment: false }
1180 steps:
1181 - run: 'true'
1182 g1t: { anything: [1, 2] }",
1183 )
1184 .unwrap();
1185 assert_eq!(workflow.g1t, Some(serde_json::json!({ "note": "top" })));
1186 assert_eq!(workflow.jobs[0].g1t, Some(serde_json::json!({ "deployment": false })));
1187 assert_eq!(workflow.jobs[0].steps[0].g1t, Some(serde_json::json!({ "anything": [1, 2] })));
1188 assert!(problem(&format!("on: push\ng1t: yes{job}")).contains("`g1t` is a mapping"));
1189 assert!(!records_deployment(&workflow.jobs[0].raw));
1190 assert!(!records_deployment(&serde_json::json!({ "environment": { "name": "p", "deployment": false } })));
1191 assert!(records_deployment(&serde_json::json!({ "environment": { "name": "p", "deployment": true } })));
1192 assert!(records_deployment(&serde_json::json!({ "environment": "p" })));
1193 }
1194
1195 #[test]
1196 fn work_items_start_workflows_and_issues_do_not() {
1197 use crate::permissions::{Access, TokenDefault};
1198 let workflow = parse(
1199 "on:
1200 work_item: { types: [opened, landed] }
1201 work_item_comment:
1202permissions: { work: write }
1203jobs:
1204 a:
1205 runs-on: x
1206 steps: [{ run: 'true' }]",
1207 )
1208 .unwrap();
1209 assert!(workflow.notes.is_empty(), "{:?}", workflow.notes);
1210 let item = workflow.trigger("work_item").unwrap();
1211 assert!(item.wants_type(Some("landed")));
1212 assert!(!item.wants_type(Some("closed")));
1213 assert!(workflow.trigger("work_item_comment").unwrap().wants_type(Some("created")));
1214 assert_eq!(workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted).get("work"), Access::Write);
1215 // GitHub's names still read, as GitHub workflows do, but start
1216 // nothing and grant nothing, and say what to write instead.
1217 let github = parse("on: [issues, issue_comment]\npermissions: { issues: write }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1218 assert!(github.trigger("work_item").is_none());
1219 let said: Vec<&str> = github.notes.iter().map(|note| note.message.as_str()).collect();
1220 assert!(said.iter().any(|note| note.contains("Write `work_item`")), "{said:?}");
1221 assert!(said.iter().any(|note| note.contains("Write `work_item_comment`")), "{said:?}");
1222 assert!(said.iter().any(|note| note.contains("Write `work`")), "{said:?}");
1223 assert_eq!(github.jobs[0].permissions(&github, TokenDefault::Restricted).scopes(), ["repo:read"]);
1224 }
1225
1226 #[test]
1227 fn aliases_read_and_merge_keys_do_not() {
1228 let aliased = parse("on: push\njobs:\n a:\n runs-on: x\n steps: &steps\n - run: 'true'\n b:\n runs-on: x\n steps: *steps").unwrap();
1229 assert_eq!(aliased.jobs[1].steps.len(), 1);
1230 let merged = parse("on: push\njobs:\n a: &job\n runs-on: x\n steps: [{ run: 'true' }]\n b:\n <<: *job\n name: B").unwrap_err();
1231 assert!(merged.contains("merge key"), "{merged}");
1232 }
1233}