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