Skip to content

Commit

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
Claudeagent and Claudeagentcommitted Parentd361fc7Browse files
10 files+158−200/10 viewed
+2−2
6868 # The larger machine: linking Tauri on the small one dies with a bus error.
6969 runs-on: g1t-4core
7070 environment: production
71− timeout-minutes: 90
71+ timeout-minutes: 60 # the most g1t's own machines run a job
7272 steps:
7373 - uses: actions/checkout@v5
7474 - name: What Tauri needs on Linux
139139 if: vars.DESKTOP_WINDOWS_FROM_LINUX == 'true' && vars.DESKTOP_WINDOWS_RUNNER != 'true'
140140 runs-on: g1t-4core
141141 environment: production
142− timeout-minutes: 90
142+ timeout-minutes: 60 # the most g1t's own machines run a job
143143 steps:
144144 - uses: actions/checkout@v5
145145 - name: What the cross build needs
+26−2
9999 Why each of these is missing, and what to use instead, is on
100100 [What g1t can't do yet](/about/limitations/#actions-and-runners).
101101
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+
102120 ## Schedules
103121
104122 A workflow with `on: schedule` runs on the default branch's latest commit,
349367 several times faster on one.
350368
351369 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.
354378
355379 ### What a job can reach
356380
+3−2
175175 `services:`, `container:`, `docker://` steps and Docker actions use
176176 the machine's Docker, as on GitHub's runners.
177177
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.
180181
181182 ## Groups
182183
+30−0
196196 pub const WORKSPACE: &str = "/home/runner/work/repo";
197197
198198 /// 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+
199214 pub fn runner_context() -> Value {
200215 json!({
201216 "name": "g1t",
259274 assert_eq!(vars["RUNNER_OS"], "Linux");
260275 }
261276 }
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+}
+50−9
409409 _ => {}
410410 }
411411 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)
413413 {
414414 note(severity, Some(id), message);
415415 }
436436 Some(id),
437437 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, ...]`."),
438438 );
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+ );
439445 }
440446 }
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+ }
441456 }
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+ }
442464 if spec.contains_key("services") {
443465 note(
444466 Severity::Info,
534556 }
535557 }
536558
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+
537565 /// What to say about `permissions` names the token does not have.
538566 fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
539567 for name in unknown {
542570 }
543571
544572 /// 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)> {
547574 if uses.starts_with("docker://") {
548575 return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine).")));
549576 }
556583 )),
557584 "actions/upload-artifact" | "actions/download-artifact" => Some((
558585 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."),
564587 )),
565588 _ => None,
566589 }
670693 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
671694 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
672695 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")));
674698 assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry")));
675699 }
676700
677701 #[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]
678719 fn self_hosted_jobs_may_run_on_any_os() {
679720 let workflow = parse(
680721 "on: push
+1−1
402402 }
403403 };
404404 let mut commands = Commands {
405− debug: false,
405+ debug: self.debug,
406406 ..Commands::default()
407407 };
408408 let ended = process::run(command, Duration::from_secs(6 * 3600).min(self.deadline_left()), &mut self.log, &mut commands);
+3−2
5757 pub const MAX_WORKFLOWS: usize = 50;
5858 /// Jobs one workspace may have running at once; the rest wait their turn.
5959 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;
6263 /// The longest a job on a self-hosted runner may run: the machine is the
6364 /// workspace's own, and its time costs nothing.
6465 pub const SELF_HOSTED_MAX_TIMEOUT_MINUTES: u32 = 24 * 60;
+19−0
300300 })
301301 }
302302
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+}
+1−1
931931 .get("timeout-minutes")
932932 .and_then(|value| expr::interpolate_value(value, &scope).ok())
933933 .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));
935935 let continue_on_error = raw
936936 .get("continue-on-error")
937937 .and_then(|value| expr::interpolate_value(value, &scope).ok())
+23−1
123123 Ok(commits)
124124 }
125125
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+
126137 async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> {
127138 let compared: Outcome<Comparison> = g1t_kit::call(
128139 &self.repos,
287298 if event_name == "pull_request_review" {
288299 let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory);
289300 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),
291302 "body": review.map(|r| r.body.clone()),
292303 "user": review.map(|r| payload::user(&r.author.username)),
293304 });
570581 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
571582 continue;
572583 };
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+ }
573595 // A change to workflows made on the remote, by someone g1t
574596 // knows nothing of, waits before it may use this repository's
575597 // secrets.