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