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.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
10 files+158−200/10 viewed
| 68 | 68 | # The larger machine: linking Tauri on the small one dies with a bus error. | |
| 69 | 69 | runs-on: g1t-4core | |
| 70 | 70 | environment: production | |
| 71 | − | timeout-minutes: 90 | |
| 71 | + | timeout-minutes: 60 # the most g1t's own machines run a job | |
| 72 | 72 | steps: | |
| 73 | 73 | - uses: actions/checkout@v5 | |
| 74 | 74 | - name: What Tauri needs on Linux | |
| ⋯ | |||
| 139 | 139 | if: vars.DESKTOP_WINDOWS_FROM_LINUX == 'true' && vars.DESKTOP_WINDOWS_RUNNER != 'true' | |
| 140 | 140 | runs-on: g1t-4core | |
| 141 | 141 | environment: production | |
| 142 | − | timeout-minutes: 90 | |
| 142 | + | timeout-minutes: 60 # the most g1t's own machines run a job | |
| 143 | 143 | steps: | |
| 144 | 144 | - uses: actions/checkout@v5 | |
| 145 | 145 | - name: What the cross build needs | |
| 99 | 99 | Why each of these is missing, and what to use instead, is on | |
| 100 | 100 | [What g1t can't do yet](/about/limitations/#actions-and-runners). | |
| 101 | 101 | ||
| 102 | + | ## Skipping a run | |
| 103 | + | ||
| 104 | + | A commit can ask not to start the `push` and `pull_request` workflows it | |
| 105 | + | would, as on GitHub: put `[skip ci]`, `[ci skip]`, `[no ci]`, | |
| 106 | + | `[skip actions]` or `[actions skip]` anywhere in its message, or end the | |
| 107 | + | message with a `skip-checks: true` trailer. For a push, the message read | |
| 108 | + | is the pushed head's; for a pull request, its head commit's. Other | |
| 109 | + | events, such as `pull_request_target`, `workflow_dispatch` and | |
| 110 | + | `schedule`, still start their workflows. | |
| 111 | + | ||
| 112 | + | ```sh | |
| 113 | + | git commit -m "Fix a typo in the README [skip ci]" | |
| 114 | + | ``` | |
| 115 | + | ||
| 116 | + | A required check that a skipped workflow would have reported stays | |
| 117 | + | pending, so a pull request whose merge needs it waits for a push that | |
| 118 | + | runs it. | |
| 119 | + | ||
| 102 | 120 | ## Schedules | |
| 103 | 121 | ||
| 104 | 122 | A workflow with `on: schedule` runs on the default branch's latest commit, | |
| ⋯ | |||
| 349 | 367 | several times faster on one. | |
| 350 | 368 | ||
| 351 | 369 | A job on g1t's machines runs for at most 60 minutes, whatever its | |
| 352 | − | `timeout-minutes`; one on a self-hosted runner can run for up to 24 hours. | |
| 353 | − | A job stopped at its time cap fails saying so. | |
| 370 | + | `timeout-minutes`, and the workflow's page warns about a job that asks | |
| 371 | + | for more. One on a self-hosted runner runs for 360 minutes when it names | |
| 372 | + | no `timeout-minutes`, as on GitHub, and can ask for up to 24 hours. A job | |
| 373 | + | stopped at its time cap fails saying so. | |
| 374 | + | ||
| 375 | + | g1t's machines are x64. A job whose `runs-on` names an Arm label, such | |
| 376 | + | as `ubuntu-24.04-arm`, runs on x64, and the workflow's page says so; a | |
| 377 | + | self-hosted Arm runner runs it on Arm. | |
| 354 | 378 | ||
| 355 | 379 | ### What a job can reach | |
| 356 | 380 | ||
| 175 | 175 | `services:`, `container:`, `docker://` steps and Docker actions use | |
| 176 | 176 | the machine's Docker, as on GitHub's runners. | |
| 177 | 177 | ||
| 178 | − | A self-hosted job stops at 60 minutes unless its `timeout-minutes` says | |
| 179 | − | more, up to 24 hours (1440). Jobs on g1t's own machines stop at 60 minutes. | |
| 178 | + | A self-hosted job stops at 360 minutes unless its `timeout-minutes` says | |
| 179 | + | otherwise, as on GitHub, and can ask for up to 24 hours (1440). Jobs on | |
| 180 | + | g1t's own machines stop at 60 minutes. | |
| 180 | 181 | ||
| 181 | 182 | ## Groups | |
| 182 | 183 |
| 196 | 196 | pub const WORKSPACE: &str = "/home/runner/work/repo"; | |
| 197 | 197 | ||
| 198 | 198 | /// The `runner` context. | |
| 199 | + | /// Whether a head commit's `message` asks, as GitHub lets it, that the | |
| 200 | + | /// `push` and `pull_request` workflows it would start don't run: `[skip ci]`, | |
| 201 | + | /// `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]` anywhere in | |
| 202 | + | /// it, or a `skip-checks: true` trailer. | |
| 203 | + | pub fn skips_ci(message: &str) -> bool { | |
| 204 | + | let lower = message.to_ascii_lowercase(); | |
| 205 | + | if ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"].iter().any(|marker| lower.contains(marker)) { | |
| 206 | + | return true; | |
| 207 | + | } | |
| 208 | + | // Trailers are the last paragraph, after a blank line. | |
| 209 | + | let trimmed = lower.trim_end(); | |
| 210 | + | let Some((_, trailers)) = trimmed.rsplit_once("\n\n") else { return false }; | |
| 211 | + | trailers.lines().any(|line| line.split_once(':').is_some_and(|(key, value)| key.trim() == "skip-checks" && value.trim() == "true")) | |
| 212 | + | } | |
| 213 | + | ||
| 199 | 214 | pub fn runner_context() -> Value { | |
| 200 | 215 | json!({ | |
| 201 | 216 | "name": "g1t", | |
| ⋯ | |||
| 259 | 274 | assert_eq!(vars["RUNNER_OS"], "Linux"); | |
| 260 | 275 | } | |
| 261 | 276 | } | |
| 277 | + | ||
| 278 | + | #[cfg(test)] | |
| 279 | + | mod skip_tests { | |
| 280 | + | use super::skips_ci; | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn a_commit_can_skip_its_runs_as_on_github() { | |
| 284 | + | for message in ["Fix typo [skip ci]", "[ci skip] docs", "Notes [no ci]", "x [skip actions]", "[Actions Skip]", "Tidy\n\nskip-checks: true", "Tidy\n\nSigned-off-by: A <a@b>\nskip-checks:true\n"] { | |
| 285 | + | assert!(skips_ci(message), "{message:?}"); | |
| 286 | + | } | |
| 287 | + | for message in ["Fix the skip ci flag", "skip-checks: true", "Tidy\n\nskip-checks: false", "[skip-ci]", "Tidy\n\nskip-checks: true\n\nMore"] { | |
| 288 | + | assert!(!skips_ci(message), "{message:?}"); | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 409 | 409 | _ => {} | |
| 410 | 410 | } | |
| 411 | 411 | if let Some(uses) = &step.uses | |
| 412 | − | && let Some((severity, message)) = action_note(uses, fields.get("with").and_then(|with| with.get("cache")).is_some()) | |
| 412 | + | && let Some((severity, message)) = action_note(uses) | |
| 413 | 413 | { | |
| 414 | 414 | note(severity, Some(id), message); | |
| 415 | 415 | } | |
| ⋯ | |||
| 436 | 436 | Some(id), | |
| 437 | 437 | format!("`runs-on: {label}`: g1t's own runners are Linux only, so this job fails. To run it on a Windows or macOS machine of your own, add a self-hosted runner and use `runs-on: [self-hosted, ...]`."), | |
| 438 | 438 | ); | |
| 439 | + | } else if lower.contains("arm") { | |
| 440 | + | note( | |
| 441 | + | Severity::Warning, | |
| 442 | + | Some(id), | |
| 443 | + | 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]`."), | |
| 444 | + | ); | |
| 439 | 445 | } | |
| 440 | 446 | } | |
| 447 | + | if let Some(minutes) = spec.get("timeout-minutes").and_then(Value::as_f64) | |
| 448 | + | && minutes > f64::from(HOSTED_MAX_TIMEOUT_MINUTES) | |
| 449 | + | { | |
| 450 | + | note( | |
| 451 | + | Severity::Warning, | |
| 452 | + | Some(id), | |
| 453 | + | 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."), | |
| 454 | + | ); | |
| 455 | + | } | |
| 441 | 456 | } | |
| 457 | + | if uses.is_some() && spec.contains_key("strategy") { | |
| 458 | + | note( | |
| 459 | + | Severity::Warning, | |
| 460 | + | Some(id), | |
| 461 | + | "`strategy` on a job that calls a reusable workflow: g1t runs the called workflow once, not once for each combination of the matrix.".to_owned(), | |
| 462 | + | ); | |
| 463 | + | } | |
| 442 | 464 | if spec.contains_key("services") { | |
| 443 | 465 | note( | |
| 444 | 466 | Severity::Info, | |
| ⋯ | |||
| 534 | 556 | } | |
| 535 | 557 | } | |
| 536 | 558 | ||
| 559 | + | /// The longest a job on g1t's own machines may run, whatever its | |
| 560 | + | /// `timeout-minutes`. | |
| 561 | + | pub const HOSTED_MAX_TIMEOUT_MINUTES: u32 = 60; | |
| 562 | + | /// How long a job may run when it names no `timeout-minutes`, as on GitHub. | |
| 563 | + | pub const DEFAULT_TIMEOUT_MINUTES: u32 = 360; | |
| 564 | + | ||
| 537 | 565 | /// What to say about `permissions` names the token does not have. | |
| 538 | 566 | fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) { | |
| 539 | 567 | for name in unknown { | |
| ⋯ | |||
| 542 | 570 | } | |
| 543 | 571 | ||
| 544 | 572 | /// What to say about an action g1t runs differently, if anything. | |
| 545 | − | /// `caches`: the step sets a `cache` input. | |
| 546 | − | fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> { | |
| 573 | + | fn action_note(uses: &str) -> Option<(Severity, String)> { | |
| 547 | 574 | if uses.starts_with("docker://") { | |
| 548 | 575 | return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine)."))); | |
| 549 | 576 | } | |
| ⋯ | |||
| 556 | 583 | )), | |
| 557 | 584 | "actions/upload-artifact" | "actions/download-artifact" => Some(( | |
| 558 | 585 | Severity::Info, | |
| 559 | − | format!("`{name}`: g1t keeps artifacts with the run for 14 days, up to 60 MB each."), | |
| 560 | − | )), | |
| 561 | − | _ if caches && name.starts_with("actions/setup-") => Some(( | |
| 562 | − | Severity::Warning, | |
| 563 | − | format!("`{name}` with `cache:` runs without that cache on g1t. Add an `actions/cache` step for the same effect."), | |
| 586 | + | format!("`{name}`: g1t keeps artifacts with the run for 14 days unless the repository says otherwise, up to 5 GiB each."), | |
| 564 | 587 | )), | |
| 565 | 588 | _ => None, | |
| 566 | 589 | } | |
| ⋯ | |||
| 670 | 693 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`"))); | |
| 671 | 694 | assert!(unsupported.iter().any(|m| m.contains("pwsh"))); | |
| 672 | 695 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache"))); | |
| 673 | − | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node"))); | |
| 696 | + | // Toolkit caches work on g1t, so `setup-node` with `cache:` says nothing. | |
| 697 | + | assert!(!workflow.notes.iter().any(|n| n.message.contains("actions/setup-node"))); | |
| 674 | 698 | assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry"))); | |
| 675 | 699 | } | |
| 676 | 700 | ||
| 677 | 701 | #[test] | |
| 702 | + | fn notes_say_what_g1ts_machines_change_about_a_job() { | |
| 703 | + | let workflow = parse( | |
| 704 | + | "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' }]", | |
| 705 | + | ) | |
| 706 | + | .unwrap(); | |
| 707 | + | let warned = |job: &str, text: &str| { | |
| 708 | + | workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.job.as_deref() == Some(job) && n.message.contains(text)) | |
| 709 | + | }; | |
| 710 | + | assert!(warned("arm", "runs on x64")); | |
| 711 | + | assert!(warned("arm", "stopped at 60 minutes")); | |
| 712 | + | assert!(warned("call", "runs the called workflow once")); | |
| 713 | + | assert!(!workflow.notes.iter().any(|n| matches!(n.job.as_deref(), Some("short" | "mine")) && n.severity == Severity::Warning)); | |
| 714 | + | let artifacts = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ uses: actions/upload-artifact@v4 }]").unwrap(); | |
| 715 | + | assert!(artifacts.notes.iter().any(|n| n.message.contains("5 GiB each"))); | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | #[test] | |
| 678 | 719 | fn self_hosted_jobs_may_run_on_any_os() { | |
| 679 | 720 | let workflow = parse( | |
| 680 | 721 | "on: push | |
| 402 | 402 | } | |
| 403 | 403 | }; | |
| 404 | 404 | let mut commands = Commands { | |
| 405 | − | debug: false, | |
| 405 | + | debug: self.debug, | |
| 406 | 406 | ..Commands::default() | |
| 407 | 407 | }; | |
| 408 | 408 | let ended = process::run(command, Duration::from_secs(6 * 3600).min(self.deadline_left()), &mut self.log, &mut commands); |
| 57 | 57 | pub const MAX_WORKFLOWS: usize = 50; | |
| 58 | 58 | /// Jobs one workspace may have running at once; the rest wait their turn. | |
| 59 | 59 | pub const RUNNING_PER_WORKSPACE: u32 = 4; | |
| 60 | − | /// The longest a job may run, whatever its `timeout-minutes`. | |
| 61 | − | pub const MAX_TIMEOUT_MINUTES: u32 = 60; | |
| 60 | + | /// The longest a job on g1t's own machines may run, whatever its | |
| 61 | + | /// `timeout-minutes`. | |
| 62 | + | pub const MAX_TIMEOUT_MINUTES: u32 = g1t_actions::workflow::HOSTED_MAX_TIMEOUT_MINUTES; | |
| 62 | 63 | /// The longest a job on a self-hosted runner may run: the machine is the | |
| 63 | 64 | /// workspace's own, and its time costs nothing. | |
| 64 | 65 | pub const SELF_HOSTED_MAX_TIMEOUT_MINUTES: u32 = 24 * 60; |
| 300 | 300 | }) | |
| 301 | 301 | } | |
| 302 | 302 | ||
| 303 | + | ||
| 304 | + | /// A review's `state` as GitHub's `pull_request_review` event spells it. | |
| 305 | + | pub(crate) fn review_state(verdict: g1t_contracts::work::Verdict) -> &'static str { | |
| 306 | + | match verdict { | |
| 307 | + | g1t_contracts::work::Verdict::Approve => "approved", | |
| 308 | + | g1t_contracts::work::Verdict::RequestChanges => "changes_requested", | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[cfg(test)] | |
| 313 | + | mod review_tests { | |
| 314 | + | use super::*; | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn review_states_are_spelled_as_githubs() { | |
| 318 | + | assert_eq!(review_state(g1t_contracts::work::Verdict::Approve), "approved"); | |
| 319 | + | assert_eq!(review_state(g1t_contracts::work::Verdict::RequestChanges), "changes_requested"); | |
| 320 | + | } | |
| 321 | + | } |
| 931 | 931 | .get("timeout-minutes") | |
| 932 | 932 | .and_then(|value| expr::interpolate_value(value, &scope).ok()) | |
| 933 | 933 | .and_then(|value| value.as_f64().or_else(|| expr::to_text(&value).parse().ok())) | |
| 934 | − | .map_or(MAX_TIMEOUT_MINUTES, |minutes| (minutes.ceil() as u32).clamp(1, max_minutes)); | |
| 934 | + | .map_or(g1t_actions::workflow::DEFAULT_TIMEOUT_MINUTES.min(max_minutes), |minutes| (minutes.ceil() as u32).clamp(1, max_minutes)); | |
| 935 | 935 | let continue_on_error = raw | |
| 936 | 936 | .get("continue-on-error") | |
| 937 | 937 | .and_then(|value| expr::interpolate_value(value, &scope).ok()) |
| 123 | 123 | Ok(commits) | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | + | /// The message of the commit `sha` in `path`, when it can be read. | |
| 127 | + | async fn head_message(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<String>> { | |
| 128 | + | let log: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 129 | + | &self.repos, | |
| 130 | + | "log", | |
| 131 | + | &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 }, | |
| 132 | + | ) | |
| 133 | + | .await?; | |
| 134 | + | Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next()).map(|commit| commit.message)) | |
| 135 | + | } | |
| 136 | + | ||
| 126 | 137 | async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> { | |
| 127 | 138 | let compared: Outcome<Comparison> = g1t_kit::call( | |
| 128 | 139 | &self.repos, | |
| ⋯ | |||
| 287 | 298 | if event_name == "pull_request_review" { | |
| 288 | 299 | let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory); | |
| 289 | 300 | payload["review"] = json!({ | |
| 290 | − | "state": review.and_then(|r| r.verdict).map(|v| format!("{v:?}").to_lowercase()), | |
| 301 | + | "state": review.and_then(|r| r.verdict).map(payload::review_state), | |
| 291 | 302 | "body": review.map(|r| r.body.clone()), | |
| 292 | 303 | "user": review.map(|r| payload::user(&r.author.username)), | |
| 293 | 304 | }); | |
| ⋯ | |||
| 570 | 581 | let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else { | |
| 571 | 582 | continue; | |
| 572 | 583 | }; | |
| 584 | + | // `[skip ci]` in the head commit's message, as on GitHub. | |
| 585 | + | if matches!(event_name, "push" | "pull_request") { | |
| 586 | + | let message = match subject.payload["head_commit"]["message"].as_str() { | |
| 587 | + | Some(message) => Some(message.to_owned()), | |
| 588 | + | None => self.head_message(&subject.source, &ws, &subject.sha).await?, | |
| 589 | + | }; | |
| 590 | + | if message.as_deref().is_some_and(g1t_actions::events::skips_ci) { | |
| 591 | + | worker::console_log!("actions: {} {} at {} asks to skip its runs", event.kind, event.id, subject.sha); | |
| 592 | + | continue; | |
| 593 | + | } | |
| 594 | + | } | |
| 573 | 595 | // A change to workflows made on the remote, by someone g1t | |
| 574 | 596 | // knows nothing of, waits before it may use this repository's | |
| 575 | 597 | // secrets. | |