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