| 24 | 24 | | use g1t_actions::permissions::{Access, TokenDefault}; |
| 25 | 25 | | let job = |name: &str, id: &str| { |
| 26 | 26 | | let workflow = read(name); |
| 27 | | − | let job = workflow.jobs.iter().find(|job| job.id == id).unwrap().clone(); |
| 27 | + | let job = workflow |
| 28 | + | .jobs |
| 29 | + | .iter() |
| 30 | + | .find(|job| job.id == id) |
| 31 | + | .unwrap() |
| 32 | + | .clone(); |
| 28 | 33 | | job.permissions(&workflow, TokenDefault::Restricted) |
| 29 | 34 | | }; |
| 30 | 35 | | // CI and Deploy only read: nothing they do writes with the token. |
| 31 | 36 | | // The actions release pushes with its own secret, never the job's token. |
| 32 | | − | for (name, id) in [("ci.yml", "rust"), ("deploy.yml", "core"), ("runner-release.yml", "binaries"), ("actions-release.yml", "publish")] { |
| 37 | + | for (name, id) in [ |
| 38 | + | ("ci.yml", "rust"), |
| 39 | + | ("deploy.yml", "core"), |
| 40 | + | ("runner-release.yml", "binaries"), |
| 41 | + | ("actions-release.yml", "publish"), |
| 42 | + | ] { |
| 33 | 43 | | let permissions = job(name, id); |
| 34 | | − | assert!(permissions.listed().iter().all(|(_, access)| *access <= Access::Read), "{name} {id}"); |
| 44 | + | assert!( |
| 45 | + | permissions |
| 46 | + | .listed() |
| 47 | + | .iter() |
| 48 | + | .all(|(_, access)| *access <= Access::Read), |
| 49 | + | "{name} {id}" |
| 50 | + | ); |
| 35 | 51 | | } |
| 36 | 52 | | // The runner base pushes a branch and opens a pull request. |
| 37 | 53 | | let base = job("runner-base.yml", "build"); |
| ⋯ |
| 54 | 70 | | } |
| 55 | 71 | | let name = path.file_name().unwrap().to_string_lossy().to_string(); |
| 56 | 72 | | let workflow = read(&name); |
| 57 | | − | let unsupported: Vec<_> = workflow.notes.iter().filter(|n| n.severity != Severity::Info).collect(); |
| 73 | + | let unsupported: Vec<_> = workflow |
| 74 | + | .notes |
| 75 | + | .iter() |
| 76 | + | .filter(|n| n.severity != Severity::Info) |
| 77 | + | .collect(); |
| 58 | 78 | | assert!(unsupported.is_empty(), "{name}: {unsupported:?}"); |
| 59 | 79 | | count += 1; |
| 60 | 80 | | } |
| ⋯ |
| 62 | 82 | | } |
| 63 | 83 | | |
| 64 | 84 | | /// The plan job's outputs for a deploy of `stages` (each with its jobs). |
| 65 | | − | fn plan_outputs(migrate: bool, core: &[(&str, &str, bool)], edge: &[(&str, &str, bool)], front: &[(&str, &str, bool)]) -> Value { |
| 85 | + | fn plan_outputs( |
| 86 | + | migrate: bool, |
| 87 | + | core: &[(&str, &str, bool)], |
| 88 | + | edge: &[(&str, &str, bool)], |
| 89 | + | front: &[(&str, &str, bool)], |
| 90 | + | ) -> Value { |
| 66 | 91 | | let matrix = |jobs: &[(&str, &str, bool)]| { |
| 67 | 92 | | let include: Vec<Value> = if jobs.is_empty() { |
| 68 | 93 | | vec![json!({ "group": "none", "units": "" })] |
| 69 | 94 | | } else { |
| 70 | | − | jobs.iter().map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust })).collect() |
| 95 | + | jobs.iter() |
| 96 | + | .map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust })) |
| 97 | + | .collect() |
| 71 | 98 | | }; |
| 72 | 99 | | json!({ "include": include }).to_string() |
| 73 | 100 | | }; |
| ⋯ |
| 87 | 114 | | /// decides it (services/actions/src/plan.rs `decide`, with |
| 88 | 115 | | /// `expr::job_status`): a need that was skipped is not a failure, and a |
| 89 | 116 | | /// failure anywhere before the job is. |
| 90 | | − | fn starts(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool) -> bool { |
| 117 | + | fn starts( |
| 118 | + | workflow: &Workflow, |
| 119 | + | job: &str, |
| 120 | + | needs: &[(&str, &str)], |
| 121 | + | outputs: &Value, |
| 122 | + | inputs: Value, |
| 123 | + | cancelled: bool, |
| 124 | + | ) -> bool { |
| 91 | 125 | | starts_after(workflow, job, needs, outputs, inputs, cancelled, false) |
| 92 | 126 | | } |
| 93 | 127 | | |
| 94 | 128 | | /// `starts`, where `failed_before` says a job further back failed (one the |
| 95 | 129 | | /// needs were skipped for). |
| 96 | | − | fn starts_after(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool, failed_before: bool) -> bool { |
| 130 | + | fn starts_after( |
| 131 | + | workflow: &Workflow, |
| 132 | + | job: &str, |
| 133 | + | needs: &[(&str, &str)], |
| 134 | + | outputs: &Value, |
| 135 | + | inputs: Value, |
| 136 | + | cancelled: bool, |
| 137 | + | failed_before: bool, |
| 138 | + | ) -> bool { |
| 97 | 139 | | let job = workflow.jobs.iter().find(|j| j.id == job).unwrap(); |
| 98 | 140 | | let mut needs_ctx = Map::new(); |
| 99 | 141 | | let mut results = Vec::new(); |
| 100 | 142 | | for need in &job.needs { |
| 101 | | − | let result = needs.iter().find(|(name, _)| name == need).map(|(_, r)| *r).unwrap_or("success"); |
| 143 | + | let result = needs |
| 144 | + | .iter() |
| 145 | + | .find(|(name, _)| name == need) |
| 146 | + | .map(|(_, r)| *r) |
| 147 | + | .unwrap_or("success"); |
| 102 | 148 | | results.push(result); |
| 103 | | − | let outputs = if need == "plan" { outputs.clone() } else { json!({}) }; |
| 104 | | − | needs_ctx.insert(need.clone(), json!({ "result": result, "outputs": outputs })); |
| 149 | + | let outputs = if need == "plan" { |
| 150 | + | outputs.clone() |
| 151 | + | } else { |
| 152 | + | json!({}) |
| 153 | + | }; |
| 154 | + | needs_ctx.insert( |
| 155 | + | need.clone(), |
| 156 | + | json!({ "result": result, "outputs": outputs }), |
| 157 | + | ); |
| 105 | 158 | | } |
| 106 | 159 | | let status = expr::job_status(results, failed_before, cancelled); |
| 107 | 160 | | let mut contexts = Map::new(); |
| 108 | 161 | | contexts.insert("needs".into(), Value::Object(needs_ctx)); |
| 109 | 162 | | contexts.insert("inputs".into(), inputs); |
| 110 | | − | contexts.insert("github".into(), json!({ "event_name": "push", "ref": "refs/heads/main" })); |
| 111 | | − | let scope = Scope { contexts: &contexts, status, hash_files: None }; |
| 163 | + | contexts.insert( |
| 164 | + | "github".into(), |
| 165 | + | json!({ "event_name": "push", "ref": "refs/heads/main" }), |
| 166 | + | ); |
| 167 | + | let scope = Scope { |
| 168 | + | contexts: &contexts, |
| 169 | + | status, |
| 170 | + | hash_files: None, |
| 171 | + | }; |
| 112 | 172 | | expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap() |
| 113 | 173 | | } |
| 114 | 174 | | |
| ⋯ |
| 125 | 185 | | let concurrency = deploy.concurrency.as_ref().unwrap(); |
| 126 | 186 | | assert_eq!(concurrency.group, "deploy-production"); |
| 127 | 187 | | assert_eq!(concurrency.cancel_in_progress, json!(false)); |
| 128 | | − | assert_eq!(deploy.job_order(), ["check", "npm-cache", "plan", "migrate", "core", "edge", "front", "smoke"]); |
| 188 | + | assert_eq!( |
| 189 | + | deploy.job_order(), |
| 190 | + | [ |
| 191 | + | "check", |
| 192 | + | "npm-cache", |
| 193 | + | "plan", |
| 194 | + | "migrate", |
| 195 | + | "core", |
| 196 | + | "edge", |
| 197 | + | "front", |
| 198 | + | "smoke" |
| 199 | + | ] |
| 200 | + | ); |
| 129 | 201 | | // Nothing waits for npm's downloads to be saved. |
| 130 | | − | assert!(deploy.jobs.iter().all(|job| !job.needs.iter().any(|n| n == "npm-cache"))); |
| 202 | + | assert!( |
| 203 | + | deploy |
| 204 | + | .jobs |
| 205 | + | .iter() |
| 206 | + | .all(|job| !job.needs.iter().any(|n| n == "npm-cache")) |
| 207 | + | ); |
| 131 | 208 | | // The stages share their steps (a YAML alias), and read the token only |
| 132 | 209 | | // where they deploy. |
| 133 | 210 | | let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len(); |
| ⋯ |
| 142 | 219 | | fn installs_everything(run: &str) -> bool { |
| 143 | 220 | | run.split('\n').any(|line| { |
| 144 | 221 | | let line = line.trim(); |
| 145 | | − | line.starts_with("npm ci") && !line.contains(" -w ") && !line.contains("--workspace") |
| 222 | + | // Installs go through npm-ci.mjs, which runs `npm ci` again when a connection drops. |
| 223 | + | (line.starts_with("npm ci") || line.starts_with("node scripts/deploy/npm-ci.mjs")) |
| 224 | + | && !line.contains(" -w ") |
| 225 | + | && !line.contains("--workspace") |
| 146 | 226 | | }) |
| 147 | 227 | | } |
| 148 | 228 | | |
| ⋯ |
| 160 | 240 | | } |
| 161 | 241 | | let name = path.file_name().unwrap().to_string_lossy().to_string(); |
| 162 | 242 | | for job in read(&name).jobs { |
| 163 | | − | let whole = job.steps.iter().any(|s| s.run.as_deref().is_some_and(installs_everything)); |
| 243 | + | let whole = job |
| 244 | + | .steps |
| 245 | + | .iter() |
| 246 | + | .any(|s| s.run.as_deref().is_some_and(installs_everything)); |
| 164 | 247 | | for step in &job.steps { |
| 165 | | − | let Some(uses) = step.uses.as_deref() else { continue }; |
| 166 | | − | if !uses.starts_with("actions/cache") || step.raw["with"]["path"] != json!("~/.npm") { |
| 248 | + | let Some(uses) = step.uses.as_deref() else { |
| 249 | + | continue; |
| 250 | + | }; |
| 251 | + | if !uses.starts_with("actions/cache") || step.raw["with"]["path"] != json!("~/.npm") |
| 252 | + | { |
| 167 | 253 | | continue; |
| 168 | 254 | | } |
| 169 | 255 | | let key = step.raw["with"]["key"].as_str().unwrap_or_default(); |
| 170 | | − | assert_eq!(key, "npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}", "{name} {}", job.id); |
| 256 | + | assert_eq!( |
| 257 | + | key, "npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}", |
| 258 | + | "{name} {}", |
| 259 | + | job.id |
| 260 | + | ); |
| 171 | 261 | | if let Some(prefix) = step.raw["with"]["restore-keys"].as_str() { |
| 172 | 262 | | assert_eq!(prefix, "npm-full-${{ runner.os }}-", "{name} {}", job.id); |
| 173 | 263 | | } |
| 174 | | − | let saves = uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@"); |
| 175 | | − | assert!(!saves || whole, "{name} {}: saves npm's cache but installs only part of the lockfile", job.id); |
| 264 | + | let saves = |
| 265 | + | uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@"); |
| 266 | + | assert!( |
| 267 | + | !saves || whole, |
| 268 | + | "{name} {}: saves npm's cache but installs only part of the lockfile", |
| 269 | + | job.id |
| 270 | + | ); |
| 176 | 271 | | if saves { |
| 177 | 272 | | saving.push(format!("{name} {}", job.id)); |
| 178 | 273 | | } |
| ⋯ |
| 180 | 275 | | } |
| 181 | 276 | | } |
| 182 | 277 | | // main's whole cache is kept by the deploy workflow, beside its check. |
| 183 | | − | assert!(saving.contains(&"deploy.yml npm-cache".to_owned()), "{saving:?}"); |
| 278 | + | assert!( |
| 279 | + | saving.contains(&"deploy.yml npm-cache".to_owned()), |
| 280 | + | "{saving:?}" |
| 281 | + | ); |
| 184 | 282 | | } |
| 185 | 283 | | |
| 186 | 284 | | #[test] |
| 187 | 285 | | fn deploy_stages_follow_one_another() { |
| 188 | 286 | | let deploy = read("deploy.yml"); |
| 189 | | − | let all = plan_outputs(true, &[("rust", "events,repos", true), ("ts", "projects", false)], &[("rust", "api", true)], &[("web", "web", false)]); |
| 287 | + | let all = plan_outputs( |
| 288 | + | true, |
| 289 | + | &[("rust", "events,repos", true), ("ts", "projects", false)], |
| 290 | + | &[("rust", "api", true)], |
| 291 | + | &[("web", "web", false)], |
| 292 | + | ); |
| 190 | 293 | | let push = json!({}); |
| 191 | 294 | | |
| 192 | 295 | | // Everything succeeds: each stage runs after the last. |
| 193 | 296 | | assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false)); |
| 194 | | − | assert!(starts(&deploy, "core", &[("migrate", "success")], &all, push.clone(), false)); |
| 195 | | − | assert!(starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), false)); |
| 196 | | − | assert!(starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "success")], &all, push.clone(), false)); |
| 297 | + | assert!(starts( |
| 298 | + | &deploy, |
| 299 | + | "core", |
| 300 | + | &[("migrate", "success")], |
| 301 | + | &all, |
| 302 | + | push.clone(), |
| 303 | + | false |
| 304 | + | )); |
| 305 | + | assert!(starts( |
| 306 | + | &deploy, |
| 307 | + | "edge", |
| 308 | + | &[("migrate", "success"), ("core", "success")], |
| 309 | + | &all, |
| 310 | + | push.clone(), |
| 311 | + | false |
| 312 | + | )); |
| 313 | + | assert!(starts( |
| 314 | + | &deploy, |
| 315 | + | "front", |
| 316 | + | &[ |
| 317 | + | ("migrate", "success"), |
| 318 | + | ("core", "success"), |
| 319 | + | ("edge", "success") |
| 320 | + | ], |
| 321 | + | &all, |
| 322 | + | push.clone(), |
| 323 | + | false |
| 324 | + | )); |
| 197 | 325 | | |
| 198 | 326 | | // No migrations: the migrate job is skipped, and core still runs. |
| 199 | | − | let none = plan_outputs(false, &[("ts", "projects", false)], &[], &[("web", "web", false)]); |
| 327 | + | let none = plan_outputs( |
| 328 | + | false, |
| 329 | + | &[("ts", "projects", false)], |
| 330 | + | &[], |
| 331 | + | &[("web", "web", false)], |
| 332 | + | ); |
| 200 | 333 | | assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false)); |
| 201 | | − | assert!(starts(&deploy, "core", &[("migrate", "skipped")], &none, push.clone(), false)); |
| 334 | + | assert!(starts( |
| 335 | + | &deploy, |
| 336 | + | "core", |
| 337 | + | &[("migrate", "skipped")], |
| 338 | + | &none, |
| 339 | + | push.clone(), |
| 340 | + | false |
| 341 | + | )); |
| 202 | 342 | | // An empty stage is skipped, and the next one still runs. |
| 203 | | − | assert!(!starts(&deploy, "edge", &[("migrate", "skipped"), ("core", "success")], &none, push.clone(), false)); |
| 204 | | − | assert!(starts(&deploy, "front", &[("migrate", "skipped"), ("core", "success"), ("edge", "skipped")], &none, push.clone(), false)); |
| 343 | + | assert!(!starts( |
| 344 | + | &deploy, |
| 345 | + | "edge", |
| 346 | + | &[("migrate", "skipped"), ("core", "success")], |
| 347 | + | &none, |
| 348 | + | push.clone(), |
| 349 | + | false |
| 350 | + | )); |
| 351 | + | assert!(starts( |
| 352 | + | &deploy, |
| 353 | + | "front", |
| 354 | + | &[ |
| 355 | + | ("migrate", "skipped"), |
| 356 | + | ("core", "success"), |
| 357 | + | ("edge", "skipped") |
| 358 | + | ], |
| 359 | + | &none, |
| 360 | + | push.clone(), |
| 361 | + | false |
| 362 | + | )); |
| 205 | 363 | | |
| 206 | 364 | | // A failure stops every later stage. |
| 207 | | − | assert!(!starts(&deploy, "core", &[("migrate", "failure")], &all, push.clone(), false)); |
| 208 | | − | assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "failure")], &all, push.clone(), false)); |
| 209 | | − | assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "failure")], &all, push.clone(), false)); |
| 210 | | − | assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "failure"), ("edge", "skipped")], &all, push.clone(), false)); |
| 365 | + | assert!(!starts( |
| 366 | + | &deploy, |
| 367 | + | "core", |
| 368 | + | &[("migrate", "failure")], |
| 369 | + | &all, |
| 370 | + | push.clone(), |
| 371 | + | false |
| 372 | + | )); |
| 373 | + | assert!(!starts( |
| 374 | + | &deploy, |
| 375 | + | "edge", |
| 376 | + | &[("migrate", "success"), ("core", "failure")], |
| 377 | + | &all, |
| 378 | + | push.clone(), |
| 379 | + | false |
| 380 | + | )); |
| 381 | + | assert!(!starts( |
| 382 | + | &deploy, |
| 383 | + | "front", |
| 384 | + | &[ |
| 385 | + | ("migrate", "success"), |
| 386 | + | ("core", "success"), |
| 387 | + | ("edge", "failure") |
| 388 | + | ], |
| 389 | + | &all, |
| 390 | + | push.clone(), |
| 391 | + | false |
| 392 | + | )); |
| 393 | + | assert!(!starts( |
| 394 | + | &deploy, |
| 395 | + | "front", |
| 396 | + | &[ |
| 397 | + | ("migrate", "success"), |
| 398 | + | ("core", "failure"), |
| 399 | + | ("edge", "skipped") |
| 400 | + | ], |
| 401 | + | &all, |
| 402 | + | push.clone(), |
| 403 | + | false |
| 404 | + | )); |
| 211 | 405 | | // A cancelled run starts nothing more. |
| 212 | | − | assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), true)); |
| 406 | + | assert!(!starts( |
| 407 | + | &deploy, |
| 408 | + | "edge", |
| 409 | + | &[("migrate", "success"), ("core", "success")], |
| 410 | + | &all, |
| 411 | + | push.clone(), |
| 412 | + | true |
| 413 | + | )); |
| 213 | 414 | | // Check and plan run side by side: plan waits for nothing. |
| 214 | 415 | | let plan = deploy.jobs.iter().find(|j| j.id == "plan").unwrap(); |
| 215 | 416 | | assert!(plan.needs.is_empty(), "{:?}", plan.needs); |
| 216 | 417 | | for id in ["migrate", "core", "edge", "front", "smoke"] { |
| 217 | 418 | | let job = deploy.jobs.iter().find(|j| j.id == id).unwrap(); |
| 218 | | − | assert!(job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"), "{id}: {:?}", job.needs); |
| 419 | + | assert!( |
| 420 | + | job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"), |
| 421 | + | "{id}: {:?}", |
| 422 | + | job.needs |
| 423 | + | ); |
| 219 | 424 | | } |
| 220 | 425 | | // A failed check, though plan succeeded: nothing migrates or deploys, |
| 221 | 426 | | // and failure() still sees the check's failure through skipped jobs. |
| 222 | | − | assert!(!starts(&deploy, "migrate", &[("check", "failure"), ("plan", "success")], &all, push.clone(), false)); |
| 223 | | − | assert!(!starts(&deploy, "core", &[("check", "failure"), ("plan", "success"), ("migrate", "skipped")], &all, push.clone(), false)); |
| 224 | | − | assert!(!starts(&deploy, "front", &[("check", "failure"), ("plan", "success"), ("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")], &all, push.clone(), false)); |
| 225 | | − | let skipped = [("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")]; |
| 226 | | − | assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true)); |
| 227 | | − | assert!(!starts(&deploy, "smoke", &[("check", "failure"), ("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, push.clone(), false)); |
| 427 | + | assert!(!starts( |
| 428 | + | &deploy, |
| 429 | + | "migrate", |
| 430 | + | &[("check", "failure"), ("plan", "success")], |
| 431 | + | &all, |
| 432 | + | push.clone(), |
| 433 | + | false |
| 434 | + | )); |
| 435 | + | assert!(!starts( |
| 436 | + | &deploy, |
| 437 | + | "core", |
| 438 | + | &[ |
| 439 | + | ("check", "failure"), |
| 440 | + | ("plan", "success"), |
| 441 | + | ("migrate", "skipped") |
| 442 | + | ], |
| 443 | + | &all, |
| 444 | + | push.clone(), |
| 445 | + | false |
| 446 | + | )); |
| 447 | + | assert!(!starts( |
| 448 | + | &deploy, |
| 449 | + | "front", |
| 450 | + | &[ |
| 451 | + | ("check", "failure"), |
| 452 | + | ("plan", "success"), |
| 453 | + | ("migrate", "skipped"), |
| 454 | + | ("core", "skipped"), |
| 455 | + | ("edge", "skipped") |
| 456 | + | ], |
| 457 | + | &all, |
| 458 | + | push.clone(), |
| 459 | + | false |
| 460 | + | )); |
| 461 | + | let skipped = [ |
| 462 | + | ("migrate", "skipped"), |
| 463 | + | ("core", "skipped"), |
| 464 | + | ("edge", "skipped"), |
| 465 | + | ]; |
| 466 | + | assert!(!starts_after( |
| 467 | + | &deploy, |
| 468 | + | "front", |
| 469 | + | &skipped, |
| 470 | + | &all, |
| 471 | + | push.clone(), |
| 472 | + | false, |
| 473 | + | true |
| 474 | + | )); |
| 475 | + | assert!(!starts( |
| 476 | + | &deploy, |
| 477 | + | "smoke", |
| 478 | + | &[ |
| 479 | + | ("check", "failure"), |
| 480 | + | ("core", "skipped"), |
| 481 | + | ("edge", "skipped"), |
| 482 | + | ("front", "skipped") |
| 483 | + | ], |
| 484 | + | &all, |
| 485 | + | push.clone(), |
| 486 | + | false |
| 487 | + | )); |
| 228 | 488 | | // A failed plan stops everything too. |
| 229 | | − | assert!(!starts(&deploy, "migrate", &[("plan", "failure")], &all, push.clone(), false)); |
| 230 | | − | assert!(!starts(&deploy, "core", &[("plan", "failure"), ("migrate", "skipped")], &all, push.clone(), false)); |
| 489 | + | assert!(!starts( |
| 490 | + | &deploy, |
| 491 | + | "migrate", |
| 492 | + | &[("plan", "failure")], |
| 493 | + | &all, |
| 494 | + | push.clone(), |
| 495 | + | false |
| 496 | + | )); |
| 497 | + | assert!(!starts( |
| 498 | + | &deploy, |
| 499 | + | "core", |
| 500 | + | &[("plan", "failure"), ("migrate", "skipped")], |
| 501 | + | &all, |
| 502 | + | push.clone(), |
| 503 | + | false |
| 504 | + | )); |
| 231 | 505 | | |
| 232 | 506 | | // Smoke follows the last stage that ran, and not a failed one. |
| 233 | | − | assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "success"), ("front", "success")], &all, push.clone(), false)); |
| 234 | | − | assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "skipped"), ("front", "success")], &none, push.clone(), false)); |
| 235 | | − | assert!(!starts(&deploy, "smoke", &[("core", "success"), ("edge", "failure"), ("front", "skipped")], &all, push.clone(), false)); |
| 507 | + | assert!(starts( |
| 508 | + | &deploy, |
| 509 | + | "smoke", |
| 510 | + | &[ |
| 511 | + | ("core", "success"), |
| 512 | + | ("edge", "success"), |
| 513 | + | ("front", "success") |
| 514 | + | ], |
| 515 | + | &all, |
| 516 | + | push.clone(), |
| 517 | + | false |
| 518 | + | )); |
| 519 | + | assert!(starts( |
| 520 | + | &deploy, |
| 521 | + | "smoke", |
| 522 | + | &[ |
| 523 | + | ("core", "success"), |
| 524 | + | ("edge", "skipped"), |
| 525 | + | ("front", "success") |
| 526 | + | ], |
| 527 | + | &none, |
| 528 | + | push.clone(), |
| 529 | + | false |
| 530 | + | )); |
| 531 | + | assert!(!starts( |
| 532 | + | &deploy, |
| 533 | + | "smoke", |
| 534 | + | &[ |
| 535 | + | ("core", "success"), |
| 536 | + | ("edge", "failure"), |
| 537 | + | ("front", "skipped") |
| 538 | + | ], |
| 539 | + | &all, |
| 540 | + | push.clone(), |
| 541 | + | false |
| 542 | + | )); |
| 236 | 543 | | // Nothing deployed: nothing to smoke-test. |
| 237 | 544 | | let nothing = plan_outputs(false, &[], &[], &[]); |
| 238 | | − | assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], ¬hing, push.clone(), false)); |
| 545 | + | assert!(!starts( |
| 546 | + | &deploy, |
| 547 | + | "smoke", |
| 548 | + | &[ |
| 549 | + | ("core", "skipped"), |
| 550 | + | ("edge", "skipped"), |
| 551 | + | ("front", "skipped") |
| 552 | + | ], |
| 553 | + | ¬hing, |
| 554 | + | push.clone(), |
| 555 | + | false |
| 556 | + | )); |
| 239 | 557 | | |
| 240 | 558 | | // A dry run plans and stops. |
| 241 | 559 | | let dry = json!({ "dry_run": true, "units": "", "all": false }); |
| 242 | 560 | | assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false)); |
| 243 | | − | assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry.clone(), false)); |
| 244 | | − | assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, dry, false)); |
| 561 | + | assert!(!starts( |
| 562 | + | &deploy, |
| 563 | + | "core", |
| 564 | + | &[("migrate", "skipped")], |
| 565 | + | &all, |
| 566 | + | dry.clone(), |
| 567 | + | false |
| 568 | + | )); |
| 569 | + | assert!(!starts( |
| 570 | + | &deploy, |
| 571 | + | "smoke", |
| 572 | + | &[ |
| 573 | + | ("core", "skipped"), |
| 574 | + | ("edge", "skipped"), |
| 575 | + | ("front", "skipped") |
| 576 | + | ], |
| 577 | + | &all, |
| 578 | + | dry, |
| 579 | + | false |
| 580 | + | )); |
| 245 | 581 | | } |
| 246 | 582 | | |
| 247 | 583 | | #[test] |
| 248 | 584 | | fn deploy_stage_matrices_come_from_the_plan() { |
| 249 | 585 | | let deploy = read("deploy.yml"); |
| 250 | | − | let outputs = plan_outputs(false, &[("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)], &[], &[]); |
| 586 | + | let outputs = plan_outputs( |
| 587 | + | false, |
| 588 | + | &[ |
| 589 | + | ("rust-1", "events,work", true), |
| 590 | + | ("rust-2", "repos", true), |
| 591 | + | ("ts", "projects,og", false), |
| 592 | + | ], |
| 593 | + | &[], |
| 594 | + | &[], |
| 595 | + | ); |
| 251 | 596 | | let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap(); |
| 252 | 597 | | assert!(!core.fail_fast); |
| 253 | 598 | | assert_eq!(core.max_parallel, Some(4)); |
| 254 | 599 | | let mut contexts = Map::new(); |
| 255 | | − | contexts.insert("needs".into(), json!({ "plan": { "result": "success", "outputs": outputs } })); |
| 256 | | − | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; |
| 600 | + | contexts.insert( |
| 601 | + | "needs".into(), |
| 602 | + | json!({ "plan": { "result": "success", "outputs": outputs } }), |
| 603 | + | ); |
| 604 | + | let scope = Scope { |
| 605 | + | contexts: &contexts, |
| 606 | + | status: Status::Success, |
| 607 | + | hash_files: None, |
| 608 | + | }; |
| 257 | 609 | | let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap(); |
| 258 | 610 | | let jobs = matrix::expand(&value).unwrap(); |
| 259 | 611 | | let groups: Vec<(&str, &str, bool)> = jobs |
| 260 | 612 | | .iter() |
| 261 | | − | .map(|c| (c["group"].as_str().unwrap(), c["units"].as_str().unwrap(), c["rust"].as_bool().unwrap())) |
| 613 | + | .map(|c| { |
| 614 | + | ( |
| 615 | + | c["group"].as_str().unwrap(), |
| 616 | + | c["units"].as_str().unwrap(), |
| 617 | + | c["rust"].as_bool().unwrap(), |
| 618 | + | ) |
| 619 | + | }) |
| 262 | 620 | | .collect(); |
| 263 | | − | assert_eq!(groups, [("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)]); |
| 621 | + | assert_eq!( |
| 622 | + | groups, |
| 623 | + | [ |
| 624 | + | ("rust-1", "events,work", true), |
| 625 | + | ("rust-2", "repos", true), |
| 626 | + | ("ts", "projects,og", false) |
| 627 | + | ] |
| 628 | + | ); |
| 264 | 629 | | } |
| 265 | 630 | | |
| 266 | 631 | | #[test] |
| ⋯ |
| 270 | 635 | | let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap(); |
| 271 | 636 | | let on = |rust: bool, image: bool| { |
| 272 | 637 | | let mut contexts = Map::new(); |
| 273 | | − | contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image })); |
| 274 | | − | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; |
| 638 | + | contexts.insert( |
| 639 | + | "matrix".into(), |
| 640 | + | json!({ "group": "g", "units": "u", "rust": rust, "image": image }), |
| 641 | + | ); |
| 642 | + | let scope = Scope { |
| 643 | + | contexts: &contexts, |
| 644 | + | status: Status::Success, |
| 645 | + | hash_files: None, |
| 646 | + | }; |
| 275 | 647 | | expr::interpolate_value(&job.runs_on, &scope).unwrap() |
| 276 | 648 | | }; |
| 277 | 649 | | assert_eq!(on(true, false), json!("g1t-4core"), "{stage}"); |
| ⋯ |
| 289 | 661 | | fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool { |
| 290 | 662 | | let mut contexts = Map::new(); |
| 291 | 663 | | contexts.insert("matrix".into(), matrix); |
| 292 | | − | let scope = Scope { contexts: &contexts, status, hash_files: None }; |
| 664 | + | let scope = Scope { |
| 665 | + | contexts: &contexts, |
| 666 | + | status, |
| 667 | + | hash_files: None, |
| 668 | + | }; |
| 293 | 669 | | expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap() |
| 294 | 670 | | } |
| 295 | 671 | | |
| 296 | 672 | | #[test] |
| 297 | 673 | | fn rust_builds_go_through_sccache_before_cargo_and_report_after() { |
| 298 | 674 | | let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) { |
| 299 | | − | let at = job.steps.iter().position(|s| s.run.as_deref() == Some(run)).unwrap_or_else(|| panic!("{}: no step runs {run}", job.id)); |
| 675 | + | let at = job |
| 676 | + | .steps |
| 677 | + | .iter() |
| 678 | + | .position(|s| s.run.as_deref() == Some(run)) |
| 679 | + | .unwrap_or_else(|| panic!("{}: no step runs {run}", job.id)); |
| 300 | 680 | | (at, job.steps[at].clone()) |
| 301 | 681 | | }; |
| 302 | 682 | | let deploy = read("deploy.yml"); |
| ⋯ |
| 306 | 686 | | let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats"); |
| 307 | 687 | | // Before anything runs Cargo (worker-build's install, the build), |
| 308 | 688 | | // and the statistics last. |
| 309 | | − | let install_step = job.steps.iter().position(|s| s.name.as_deref() == Some("Install")).unwrap(); |
| 689 | + | let install_step = job |
| 690 | + | .steps |
| 691 | + | .iter() |
| 692 | + | .position(|s| s.name.as_deref() == Some("Install")) |
| 693 | + | .unwrap(); |
| 310 | 694 | | assert!(install_at < install_step, "{stage}"); |
| 311 | 695 | | assert_eq!(stats_at, job.steps.len() - 1, "{stage}"); |
| 312 | | − | for (rust, image, uses) in [(true, false, true), (false, true, true), (false, false, false)] { |
| 696 | + | for (rust, image, uses) in [ |
| 697 | + | (true, false, true), |
| 698 | + | (false, true, true), |
| 699 | + | (false, false, false), |
| 700 | + | ] { |
| 313 | 701 | | let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image }); |
| 314 | | − | assert_eq!(step_runs(&install, matrix.clone(), Status::Success), uses, "{stage} rust={rust} image={image}"); |
| 315 | | − | assert_eq!(step_runs(&stats, matrix.clone(), Status::Success), uses, "{stage}"); |
| 702 | + | assert_eq!( |
| 703 | + | step_runs(&install, matrix.clone(), Status::Success), |
| 704 | + | uses, |
| 705 | + | "{stage} rust={rust} image={image}" |
| 706 | + | ); |
| 707 | + | assert_eq!( |
| 708 | + | step_runs(&stats, matrix.clone(), Status::Success), |
| 709 | + | uses, |
| 710 | + | "{stage}" |
| 711 | + | ); |
| 316 | 712 | | // A failed build still says what was cached. |
| 317 | 713 | | assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}"); |
| 318 | 714 | | } |
| ⋯ |
| 331 | 727 | | let job = ci.jobs.iter().find(|j| j.id == id).unwrap(); |
| 332 | 728 | | for (event, expected) in [("push", on_push), ("pull_request", true)] { |
| 333 | 729 | | let mut contexts = Map::new(); |
| 334 | | − | contexts.insert("github".into(), json!({ "event_name": event, "ref": "refs/heads/main" })); |
| 335 | | − | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; |
| 336 | | − | assert_eq!(expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(), expected, "{id} on {event}"); |
| 730 | + | contexts.insert( |
| 731 | + | "github".into(), |
| 732 | + | json!({ "event_name": event, "ref": "refs/heads/main" }), |
| 733 | + | ); |
| 734 | + | let scope = Scope { |
| 735 | + | contexts: &contexts, |
| 736 | + | status: Status::Success, |
| 737 | + | hash_files: None, |
| 738 | + | }; |
| 739 | + | assert_eq!( |
| 740 | + | expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(), |
| 741 | + | expected, |
| 742 | + | "{id} on {event}" |
| 743 | + | ); |
| 337 | 744 | | } |
| 338 | 745 | | } |
| 339 | 746 | | // The download is pinned by version and checksum. |
| 340 | 747 | | let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap(); |
| 341 | 748 | | assert!(script.contains("VERSION=0.18.0")); |
| 342 | | − | assert!(script.lines().any(|line| line.strip_prefix("SHA256=").is_some_and(|sum| sum.len() == 64))); |
| 749 | + | assert!(script.lines().any(|line| { |
| 750 | + | line.strip_prefix("SHA256=") |
| 751 | + | .is_some_and(|sum| sum.len() == 64) |
| 752 | + | })); |
| 343 | 753 | | assert!(script.contains("sha256sum -c")); |
| 344 | 754 | | assert!(script.contains("RUSTC_WRAPPER=")); |
| 345 | 755 | | assert!(script.contains("GITHUB_STEP_SUMMARY")); |
| ⋯ |
| 361 | 771 | | let mut finders = Vec::new(); |
| 362 | 772 | | for entry in std::fs::read_dir(&actions).unwrap() { |
| 363 | 773 | | let dir = entry.unwrap().path(); |
| 364 | | − | let manifest = std::fs::read_to_string(dir.join("action.yml")).unwrap_or_else(|_| panic!("{} has no action.yml", dir.display())); |
| 774 | + | let manifest = std::fs::read_to_string(dir.join("action.yml")) |
| 775 | + | .unwrap_or_else(|_| panic!("{} has no action.yml", dir.display())); |
| 365 | 776 | | let manifest: serde_yaml::Value = serde_yaml::from_str(&manifest).unwrap(); |
| 366 | | − | assert_eq!(manifest["runs"]["using"].as_str(), Some("composite"), "{}", dir.display()); |
| 777 | + | assert_eq!( |
| 778 | + | manifest["runs"]["using"].as_str(), |
| 779 | + | Some("composite"), |
| 780 | + | "{}", |
| 781 | + | dir.display() |
| 782 | + | ); |
| 367 | 783 | | assert!(dir.join("README.md").exists(), "{}", dir.display()); |
| 368 | 784 | | finders.push(std::fs::read_to_string(dir.join("runner.sh")).unwrap()); |
| 369 | 785 | | } |
| 370 | 786 | | assert!(finders.len() >= 2); |
| 371 | | − | assert!(finders.windows(2).all(|pair| pair[0] == pair[1]), "actions/*/runner.sh differ"); |
| 372 | | − | let deploy: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap()).unwrap(); |
| 373 | | − | assert_eq!(deploy["inputs"]["token"]["default"].as_str(), Some("${{ github.token }}")); |
| 787 | + | assert!( |
| 788 | + | finders.windows(2).all(|pair| pair[0] == pair[1]), |
| 789 | + | "actions/*/runner.sh differ" |
| 790 | + | ); |
| 791 | + | let deploy: serde_yaml::Value = |
| 792 | + | serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap()) |
| 793 | + | .unwrap(); |
| 794 | + | assert_eq!( |
| 795 | + | deploy["inputs"]["token"]["default"].as_str(), |
| 796 | + | Some("${{ github.token }}") |
| 797 | + | ); |
| 374 | 798 | | for output in ["url", "deployment-id"] { |
| 375 | 799 | | assert!(deploy["outputs"][output]["value"].is_string(), "{output}"); |
| 376 | 800 | | } |
| ⋯ |
| 388 | 812 | | |
| 389 | 813 | | #[test] |
| 390 | 814 | | fn the_conformance_workflow_is_the_same_on_github_and_on_g1t() { |
| 391 | | − | let root = workflows_dir().parent().unwrap().parent().unwrap().to_path_buf(); |
| 815 | + | let root = workflows_dir() |
| 816 | + | .parent() |
| 817 | + | .unwrap() |
| 818 | + | .parent() |
| 819 | + | .unwrap() |
| 820 | + | .to_path_buf(); |
| 392 | 821 | | let ours = std::fs::read_to_string(root.join(".g1t/workflows/conformance.yml")).unwrap(); |
| 393 | 822 | | let theirs = std::fs::read_to_string(root.join(".github/workflows/conformance.yml")).unwrap(); |
| 394 | | − | assert_eq!(ours, theirs, "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml"); |
| 823 | + | assert_eq!( |
| 824 | + | ours, theirs, |
| 825 | + | "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml" |
| 826 | + | ); |
| 395 | 827 | | // It runs only by hand, and reads nothing it could change. |
| 396 | 828 | | let workflow = read("conformance.yml"); |
| 397 | | − | assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["workflow_dispatch"]); |
| 829 | + | assert_eq!( |
| 830 | + | workflow |
| 831 | + | .triggers |
| 832 | + | .iter() |
| 833 | + | .map(|t| t.event.as_str()) |
| 834 | + | .collect::<Vec<_>>(), |
| 835 | + | ["workflow_dispatch"] |
| 836 | + | ); |
| 398 | 837 | | assert!(!ours.contains("secrets.")); |
| 399 | 838 | | } |
| 400 | 839 | | |
| ⋯ |
| 412 | 851 | | } |
| 413 | 852 | | let source = std::fs::read_to_string(&path).unwrap(); |
| 414 | 853 | | for (at, _) in source.match_indices("secrets.") { |
| 415 | | − | let name: String = source[at + "secrets.".len()..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); |
| 854 | + | let name: String = source[at + "secrets.".len()..] |
| 855 | + | .chars() |
| 856 | + | .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') |
| 857 | + | .collect(); |
| 416 | 858 | | let reserved = name.starts_with("G1T_") || name.starts_with("GITHUB_"); |
| 417 | | − | assert!(!reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN", "{} reads secrets.{name}, a name g1t keeps for its own", path.display()); |
| 859 | + | assert!( |
| 860 | + | !reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN", |
| 861 | + | "{} reads secrets.{name}, a name g1t keeps for its own", |
| 862 | + | path.display() |
| 863 | + | ); |
| 864 | + | } |
| 865 | + | } |
| 866 | + | } |
| 867 | + | |
| 868 | + | #[test] |
| 869 | + | fn every_install_runs_again_when_a_connection_drops() { |
| 870 | + | // npm does not retry a body that breaks off midway; npm-ci.mjs does. |
| 871 | + | for entry in std::fs::read_dir(workflows_dir()).unwrap() { |
| 872 | + | let path = entry.unwrap().path(); |
| 873 | + | if path.extension().and_then(|e| e.to_str()) != Some("yml") { |
| 874 | + | continue; |
| 875 | + | } |
| 876 | + | let name = path.file_name().unwrap().to_string_lossy().to_string(); |
| 877 | + | for job in read(&name).jobs { |
| 878 | + | for step in &job.steps { |
| 879 | + | let Some(run) = step.run.as_deref() else { |
| 880 | + | continue; |
| 881 | + | }; |
| 882 | + | for line in run.lines() { |
| 883 | + | assert!( |
| 884 | + | !line.trim().starts_with("npm ci"), |
| 885 | + | "{name} {}: `{}` should be `node scripts/deploy/npm-ci.mjs …`", |
| 886 | + | job.id, |
| 887 | + | line.trim() |
| 888 | + | ); |
| 889 | + | } |
| 890 | + | } |
| 418 | 891 | | } |
| 419 | 892 | | } |
| 420 | 893 | | } |