Skip to content
837 linesCodeBlameRaw

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.

Social cards for every page: og.g1t.sh1//! 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
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7
8use crate::filter::{Filter, Patterns};
Merge branch 'worktree-agent-a3abfcce648e87dca'9use crate::permissions::{self, Permissions};
Social cards for every page: og.g1t.sh10
11/// Where workflows live: GitHub's `.github/workflows`, under g1t's own
12/// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
Merge branch 'mirroring' into artifacts-mode13/// g1t reads `.github` only for a mirror of a GitHub repository in CI
14/// failover or taken over (services/actions/src/mirrored.rs); otherwise it
15/// stays GitHub's.
Social cards for every page: og.g1t.sh16pub const FOLDER: &str = ".g1t/workflows";
17
18/// The events a workflow can name that g1t starts runs for.
19pub const SUPPORTED_EVENTS: &[&str] = &[
20 "push",
21 "pull_request",
22 "pull_request_target",
23 "pull_request_review",
24 "issues",
25 "issue_comment",
26 "schedule",
27 "workflow_dispatch",
28 "repository_dispatch",
29 "workflow_call",
30 "workflow_run",
31 "merge_group",
32 "create",
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts33 "release",
34 "deployment",
35 "deployment_status",
Social cards for every page: og.g1t.sh36];
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.
40pub const UNSENT_EVENTS: &[(&str, &str)] = &[(
41 "delete",
42 "g1t does not start runs when a branch or tag is deleted yet, so the `delete` trigger never starts it. New branches and tags start `create` and `push` workflows.",
43)];
44
Social cards for every page: og.g1t.sh45/// The `types` each event has when a workflow gives none, as on GitHub.
46pub 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")]
57pub 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)]
67pub 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)]
77pub 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
92impl 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)]
111pub struct Step {
112 pub id: Option<String>,
113 pub name: Option<String>,
114 pub condition: Option<String>,
115 pub uses: Option<String>,
116 pub run: Option<String>,
117 /// The whole step as written, for the sandbox.
118 pub raw: Value,
119}
120
121impl Step {
122 /// How the step is shown when it has no name.
123 pub fn title(&self) -> String {
124 if let Some(name) = &self.name {
125 return name.clone();
126 }
127 if let Some(uses) = &self.uses {
128 return format!("Run {uses}");
129 }
130 let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
131 format!("Run {}", first.trim())
132 }
133}
134
135#[derive(Clone, Debug, PartialEq)]
136pub struct Job {
137 /// Its key under `jobs:`.
138 pub id: String,
139 pub name: Option<String>,
140 pub needs: Vec<String>,
141 pub condition: Option<String>,
142 pub runs_on: Value,
143 /// `strategy.matrix`, as written (it may be an expression).
144 pub matrix: Option<Value>,
145 pub fail_fast: bool,
146 pub max_parallel: Option<u32>,
147 /// A reusable workflow it calls (`uses:` on a job).
148 pub uses: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'149 /// Its own `permissions`, which replace the workflow's.
150 pub permissions: Option<Permissions>,
151 /// Its own `concurrency`: at most one job of its group runs at a time.
152 pub concurrency: Option<Concurrency>,
Social cards for every page: og.g1t.sh153 pub steps: Vec<Step>,
154 /// The whole job as written, for the sandbox.
155 pub raw: Value,
156}
157
Merge branch 'worktree-agent-a3abfcce648e87dca'158impl Job {
159 /// What its token may do: its own `permissions`, else its workflow's,
160 /// else `default` (the repository's choice).
161 pub fn permissions(&self, workflow: &Workflow, default: permissions::TokenDefault) -> Permissions {
162 self.permissions
163 .clone()
164 .or_else(|| workflow.permissions.clone())
165 .unwrap_or_else(|| Permissions::default_for(default))
166 }
167}
168
Social cards for every page: og.g1t.sh169#[derive(Clone, Debug, PartialEq)]
170pub struct Workflow {
171 pub name: Option<String>,
172 pub run_name: Option<String>,
173 pub triggers: Vec<Trigger>,
174 pub env: Map<String, Value>,
175 pub concurrency: Option<Concurrency>,
Merge branch 'worktree-agent-a3abfcce648e87dca'176 /// Its top-level `permissions`, for every job that writes none.
177 pub permissions: Option<Permissions>,
Social cards for every page: og.g1t.sh178 pub jobs: Vec<Job>,
179 pub notes: Vec<Note>,
180 /// The whole workflow as written.
181 pub raw: Value,
182}
183
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct Concurrency {
186 /// May hold an expression.
187 pub group: String,
188 pub cancel_in_progress: Value,
189}
190
191impl Workflow {
192 pub fn trigger(&self, event: &str) -> Option<&Trigger> {
193 self.triggers.iter().find(|trigger| trigger.event == event)
194 }
195
196 /// The name shown for it: its `name`, or its file's path.
197 pub fn display_name(&self, path: &str) -> String {
198 self.name.clone().unwrap_or_else(|| path.to_owned())
199 }
200
201 /// The job ids in an order where each comes after the jobs it needs.
202 pub fn job_order(&self) -> Vec<&str> {
203 let mut ordered: Vec<&str> = Vec::new();
204 while ordered.len() < self.jobs.len() {
205 let before = ordered.len();
206 for job in &self.jobs {
207 if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
208 ordered.push(&job.id);
209 }
210 }
211 if ordered.len() == before {
212 break;
213 }
214 }
215 ordered
216 }
217}
218
219/// YAML to JSON, keeping the order of keys. Keys that are not strings
220/// (`on: true` in YAML 1.1, numbers) become their text.
221pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
222 match value {
223 serde_yaml::Value::Null => Value::Null,
224 serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
225 serde_yaml::Value::Number(number) => {
226 if let Some(n) = number.as_i64() {
227 Value::from(n)
228 } else if let Some(n) = number.as_u64() {
229 Value::from(n)
230 } else {
231 number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
232 }
233 }
234 serde_yaml::Value::String(text) => Value::String(text.clone()),
235 serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
236 serde_yaml::Value::Mapping(map) => {
237 let mut out = Map::new();
238 for (key, value) in map {
239 let key = match key {
240 serde_yaml::Value::String(text) => text.clone(),
241 serde_yaml::Value::Bool(flag) => flag.to_string(),
242 serde_yaml::Value::Number(number) => number.to_string(),
243 _ => continue,
244 };
245 out.insert(key, yaml_to_json(value));
246 }
247 Value::Object(out)
248 }
249 serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
250 }
251}
252
253fn texts(value: Option<&Value>) -> Vec<String> {
254 match value {
255 Some(Value::String(text)) => vec![text.clone()],
256 Some(Value::Array(items)) => items
257 .iter()
258 .filter_map(|item| match item {
259 Value::String(text) => Some(text.clone()),
260 Value::Number(n) => Some(n.to_string()),
261 _ => None,
262 })
263 .collect(),
264 _ => Vec::new(),
265 }
266}
267
268fn text(value: Option<&Value>) -> Option<String> {
269 match value? {
270 Value::String(text) => Some(text.clone()),
271 Value::Number(n) => Some(n.to_string()),
272 Value::Bool(flag) => Some(flag.to_string()),
273 _ => None,
274 }
275}
276
277fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
278 let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
279 Filter { only: list(only), ignore: list(ignore) }
280}
281
282fn trigger(event: &str, spec: &Value) -> Trigger {
283 let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
284 match spec {
285 Value::Object(spec) => {
286 trigger.types = texts(spec.get("types"));
287 trigger.branches = filter(spec, "branches", "branches-ignore");
288 trigger.tags = filter(spec, "tags", "tags-ignore");
289 trigger.paths = filter(spec, "paths", "paths-ignore");
290 if let Some(Value::Object(inputs)) = spec.get("inputs") {
291 trigger.inputs = inputs.clone();
292 }
293 trigger.workflows = texts(spec.get("workflows"));
294 }
295 Value::Array(entries) if event == "schedule" => {
296 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
297 }
298 _ => {}
299 }
300 trigger
301}
302
303/// Reads a workflow. `Err` is what is wrong with the file, for the person
304/// who wrote it; what reads but runs differently is in `notes`.
305pub fn parse(source: &str) -> Result<Workflow, String> {
306 let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
307 let raw = yaml_to_json(&yaml);
308 let Value::Object(root) = &raw else {
309 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
310 };
311 let mut notes = Vec::new();
312 let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
313
314 // `on`, in any of its three shapes. YAML 1.1 readers turn `on` into
315 // `true`; this reader keeps it, and accepts both.
316 let on = root.get("on").or_else(|| root.get("true")).ok_or("`on` is missing: say which events start the workflow.")?;
317 let mut triggers = Vec::new();
318 match on {
319 Value::String(event) => triggers.push(trigger(event, &Value::Null)),
320 Value::Array(events) => {
321 for event in events {
322 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
323 triggers.push(trigger(event, &Value::Null));
324 }
325 }
326 Value::Object(events) => {
327 for (event, spec) in events {
328 triggers.push(trigger(event, spec));
329 }
330 }
331 _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
332 }
333 for trigger in &triggers {
Merge branch 'worktree-agent-a3abfcce648e87dca'334 if let Some((_, why)) = UNSENT_EVENTS.iter().find(|(event, _)| *event == trigger.event) {
335 note(Severity::Unsupported, None, (*why).to_owned());
336 } else if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
Social cards for every page: og.g1t.sh337 note(
338 Severity::Unsupported,
339 None,
340 format!("g1t has no `{}` event, so that trigger never starts it.", trigger.event),
341 );
342 }
343 if trigger.event == "pull_request_target" {
344 note(
345 Severity::Info,
346 None,
Merge branch 'worktree-agent-a3abfcce648e87dca'347 "`pull_request_target` runs in the base's context: the default branch's copy of this workflow, at the default branch's head, with the repository's secrets. It does not check out the pull request's changes; a step that does runs code anyone could have written, with those secrets.".to_owned(),
Social cards for every page: og.g1t.sh348 );
349 }
350 if trigger.event == "workflow_call" && triggers.len() == 1 {
351 note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
352 }
353 }
354
355 let env = match root.get("env") {
356 Some(Value::Object(env)) => env.clone(),
357 _ => Map::new(),
358 };
Merge branch 'worktree-agent-a3abfcce648e87dca'359 let concurrency = concurrency_of(root.get("concurrency"));
360 let permissions = match root.get("permissions") {
361 None => None,
362 Some(value) => {
363 let (permissions, unknown) = permissions::parse(value)?;
364 permission_notes(&unknown, None, &mut note);
365 Some(permissions)
366 }
Social cards for every page: og.g1t.sh367 };
368
369 let Some(Value::Object(job_specs)) = root.get("jobs") else {
370 return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
371 };
372 if job_specs.is_empty() {
373 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
374 }
375 let mut jobs = Vec::new();
376 for (id, spec) in job_specs {
377 let Value::Object(spec) = spec else {
378 return Err(format!("Job `{id}` is a mapping."));
379 };
380 let uses = text(spec.get("uses"));
381 let steps_raw = match spec.get("steps") {
382 Some(Value::Array(steps)) => steps.clone(),
383 None if uses.is_some() => Vec::new(),
384 None => return Err(format!("Job `{id}` has no `steps`.")),
385 Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
386 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily387 let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
388 let labels: Vec<String> =
389 texts(Some(&runs_on)).into_iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default()).collect();
390 // `self-hosted`, or a runner group, sends the job to the workspace's
391 // own runners, which may be Linux, macOS or Windows.
392 let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted"));
Social cards for every page: og.g1t.sh393 let mut steps = Vec::new();
394 for (index, step) in steps_raw.iter().enumerate() {
395 let Value::Object(fields) = step else {
396 return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
397 };
398 let step = Step {
399 id: text(fields.get("id")),
400 name: text(fields.get("name")),
401 condition: text(fields.get("if")),
402 uses: text(fields.get("uses")),
403 run: text(fields.get("run")),
404 raw: step.clone(),
405 };
406 match (&step.uses, &step.run) {
407 (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
408 (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
409 _ => {}
410 }
411 if let Some(uses) = &step.uses
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.412 && let Some((severity, message)) = action_note(uses)
Social cards for every page: og.g1t.sh413 {
414 note(severity, Some(id), message);
415 }
416 if let Some(shell) = text(fields.get("shell"))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily417 && !self_hosted
Social cards for every page: og.g1t.sh418 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
419 {
420 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
421 }
422 steps.push(step);
423 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily424 if self_hosted {
425 note(
426 Severity::Info,
427 Some(id),
428 "`self-hosted`: the job runs on one of the workspace's self-hosted runners that has every label in its `runs-on`, and waits until one does.".to_owned(),
429 );
430 } else {
431 for label in &labels {
432 let lower = label.to_ascii_lowercase();
433 if lower.contains("windows") || lower.contains("macos") {
434 note(
435 Severity::Unsupported,
436 Some(id),
437 format!("`runs-on: {label}`: g1t's own runners are Linux only, so this job fails. To run it on a Windows or macOS machine of your own, add a self-hosted runner and use `runs-on: [self-hosted, ...]`."),
438 );
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.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 );
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily445 }
Social cards for every page: og.g1t.sh446 }
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.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 }
Social cards for every page: og.g1t.sh456 }
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.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 }
Social cards for every page: og.g1t.sh464 if spec.contains_key("services") {
Merge branch 'main' into actions-toolkit-oidc-artifacts465 note(
466 Severity::Info,
467 Some(id),
468 "`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(),
469 );
Social cards for every page: og.g1t.sh470 }
471 if spec.contains_key("container") {
Merge branch 'main' into actions-toolkit-oidc-artifacts472 note(
473 Severity::Info,
474 Some(id),
475 "`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(),
476 );
Social cards for every page: og.g1t.sh477 }
478 if spec.contains_key("environment") {
Merge branch 'worktree-agent-a3abfcce648e87dca'479 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());
Social cards for every page: og.g1t.sh480 }
Merge branch 'worktree-agent-a3abfcce648e87dca'481 let job_permissions = match spec.get("permissions") {
482 None => None,
483 Some(value) => {
484 let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?;
485 permission_notes(&unknown, Some(id), &mut note);
486 Some(permissions)
487 }
488 };
Social cards for every page: og.g1t.sh489 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
490 Some(Value::Object(strategy)) => (
491 strategy.get("matrix").cloned(),
492 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
493 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
494 ),
495 _ => (None, true, None),
496 };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts497 if let Some(called) = uses.as_deref().filter(|uses| !uses.starts_with("./")) {
Social cards for every page: og.g1t.sh498 note(
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts499 Severity::Info,
Social cards for every page: og.g1t.sh500 Some(id),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts501 format!(
502 "`{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."
503 ),
Social cards for every page: og.g1t.sh504 );
505 }
506 jobs.push(Job {
507 id: id.clone(),
508 name: text(spec.get("name")),
509 needs: texts(spec.get("needs")),
510 condition: text(spec.get("if")),
511 runs_on,
512 matrix,
513 fail_fast,
514 max_parallel,
515 uses,
Merge branch 'worktree-agent-a3abfcce648e87dca'516 permissions: job_permissions,
517 concurrency: concurrency_of(spec.get("concurrency")),
Social cards for every page: og.g1t.sh518 steps,
519 raw: Value::Object(spec.clone()),
520 });
521 }
522 for job in &jobs {
523 for need in &job.needs {
524 if !jobs.iter().any(|other| &other.id == need) {
525 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
526 }
527 }
528 }
529 let workflow = Workflow {
530 name: text(root.get("name")),
531 run_name: text(root.get("run-name")),
532 triggers,
533 env,
534 concurrency,
Merge branch 'worktree-agent-a3abfcce648e87dca'535 permissions,
Social cards for every page: og.g1t.sh536 jobs,
537 notes,
538 raw,
539 };
540 if workflow.job_order().len() < workflow.jobs.len() {
541 return Err("The jobs' `needs` go round in a circle.".to_owned());
542 }
543 Ok(workflow)
544}
545
Merge branch 'worktree-agent-a3abfcce648e87dca'546/// `concurrency`, as a group's name or a mapping with `group` and
547/// `cancel-in-progress`.
548fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> {
549 match value {
550 Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
551 Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
552 group,
553 cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
554 }),
555 _ => None,
556 }
557}
558
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.559/// The longest a job on g1t's own machines may run, whatever its
560/// `timeout-minutes`.
561pub const HOSTED_MAX_TIMEOUT_MINUTES: u32 = 60;
562/// How long a job may run when it names no `timeout-minutes`, as on GitHub.
563pub const DEFAULT_TIMEOUT_MINUTES: u32 = 360;
564
Merge branch 'worktree-agent-a3abfcce648e87dca'565/// What to say about `permissions` names the token does not have.
566fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
567 for name in unknown {
568 note(Severity::Warning, job, format!("`permissions.{name}`: the token has no permission called that, so it grants nothing."));
569 }
570}
571
Social cards for every page: og.g1t.sh572/// 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.573fn action_note(uses: &str) -> Option<(Severity, String)> {
Social cards for every page: og.g1t.sh574 if uses.starts_with("docker://") {
Merge branch 'main' into actions-toolkit-oidc-artifacts575 return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine).")));
Social cards for every page: og.g1t.sh576 }
577 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
578 match name.as_str() {
579 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
580 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
581 Severity::Info,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily582 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."),
Social cards for every page: og.g1t.sh583 )),
584 "actions/upload-artifact" | "actions/download-artifact" => Some((
585 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.586 format!("`{name}`: g1t keeps artifacts with the run for 14 days unless the repository says otherwise, up to 5 GiB each."),
Social cards for every page: og.g1t.sh587 )),
588 _ => None,
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 const CI: &str = r#"
597name: CI
598on:
599 push:
600 branches: [main]
601 paths-ignore: ["docs/**"]
602 pull_request:
603 workflow_dispatch:
604 inputs:
605 debug:
606 type: boolean
607 default: false
608 schedule:
609 - cron: "0 3 * * *"
610concurrency:
611 group: ci-${{ github.ref }}
612 cancel-in-progress: true
613env:
614 CARGO_TERM_COLOR: always
615jobs:
616 test:
617 runs-on: ${{ matrix.os }}
618 strategy:
619 matrix:
620 os: [ubuntu-latest, windows-latest]
621 node: [22, 24]
622 steps:
623 - uses: actions/checkout@v7
624 - uses: actions/setup-node@v7
625 with:
626 node-version: ${{ matrix.node }}
627 - run: npm ci
628 - name: Test
629 run: npm test
630 deploy:
631 needs: test
632 if: github.ref == 'refs/heads/main'
633 runs-on: ubuntu-latest
634 steps:
635 - run: echo deploy
636"#;
637
638 #[test]
639 fn a_whole_workflow_reads() {
640 let workflow = parse(CI).unwrap();
641 assert_eq!(workflow.name.as_deref(), Some("CI"));
642 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
643 let push = workflow.trigger("push").unwrap();
644 assert!(push.branches.allows("main"));
645 assert!(!push.branches.allows("dev"));
646 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
647 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
648 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
649 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
650 assert_eq!(workflow.jobs.len(), 2);
651 assert_eq!(workflow.jobs[1].needs, ["test"]);
652 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7");
653 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
654 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
655 assert_eq!(workflow.job_order(), ["test", "deploy"]);
656 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
657 }
658
659 #[test]
660 fn short_forms_of_on() {
661 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
662 assert_eq!(one.triggers[0].event, "push");
663 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
664 assert_eq!(list.triggers.len(), 2);
665 let pr = list.trigger("pull_request").unwrap();
666 assert!(pr.wants_type(Some("opened")));
667 assert!(pr.wants_type(Some("synchronize")));
668 assert!(!pr.wants_type(Some("closed")));
669 assert!(pr.wants_type(Some("ready_for_review")));
670 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
671 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
672 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
673 }
674
675 #[test]
676 fn notes_say_what_runs_differently() {
677 let workflow = parse(
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts678 "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",
Social cards for every page: og.g1t.sh679 )
680 .unwrap();
681 let unsupported: Vec<&str> =
682 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 timeouts683 assert!(unsupported.iter().any(|m| m.contains("`watch`")));
684 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();
685 assert!(!released.notes.iter().any(|n| n.severity == Severity::Unsupported));
686 assert!(released.trigger("release").unwrap().wants_type(Some("published")));
687 assert!(!released.trigger("release").unwrap().wants_type(Some("created")));
688 assert!(released.trigger("deployment_status").unwrap().wants_type(Some("created")));
Social cards for every page: og.g1t.sh689 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
Merge branch 'main' into actions-toolkit-oidc-artifacts690 assert!(!unsupported.iter().any(|m| m.contains("services")));
691 assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
692 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost")));
693 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
Social cards for every page: og.g1t.sh694 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
695 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.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")));
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily698 assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry")));
699 }
700
701 #[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.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]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily719 fn self_hosted_jobs_may_run_on_any_os() {
720 let workflow = parse(
721 "on: push
722jobs:
723 win:
724 runs-on: [self-hosted, windows]
725 steps:
726 - run: dir
727 shell: pwsh
728 mac:
729 runs-on: { group: Macs, labels: [macos] }
730 steps: [{ run: 'true' }]",
731 )
732 .unwrap();
733 assert!(!workflow.notes.iter().any(|n| n.severity == Severity::Unsupported), "{:?}", workflow.notes);
734 let routed: Vec<&str> = workflow.notes.iter().filter(|n| n.message.starts_with("`self-hosted`")).map(|n| n.message.as_str()).collect();
735 assert_eq!(routed.len(), 2);
736 assert!(routed.iter().all(|m| m.contains("self-hosted runners") && !m.contains("Linux")));
Social cards for every page: og.g1t.sh737 }
738
739 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'740 fn permissions_are_read_at_both_levels() {
741 use crate::permissions::{Access, TokenDefault};
742 let workflow = parse(
743 "on: push
744permissions:
745 contents: read
746 pull-requests: write
747jobs:
748 plain:
749 runs-on: ubuntu-latest
750 steps: [{ run: 'true' }]
751 release:
752 runs-on: ubuntu-latest
753 permissions:
754 contents: write
755 steps: [{ run: 'true' }]
756 quiet:
757 runs-on: ubuntu-latest
758 permissions: {}
759 steps: [{ run: 'true' }]",
760 )
761 .unwrap();
762 let plain = workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted);
763 assert_eq!(plain.get("pull-requests"), Access::Write);
764 assert_eq!(plain.get("contents"), Access::Read);
765 // A job's own permissions replace the workflow's whole.
766 let release = workflow.jobs[1].permissions(&workflow, TokenDefault::Permissive);
767 assert_eq!(release.get("contents"), Access::Write);
768 assert_eq!(release.get("pull-requests"), Access::None);
769 assert_eq!(workflow.jobs[2].permissions(&workflow, TokenDefault::Permissive).scopes(), ["repo:read"]);
770 // Without any, the repository's default.
771 let bare = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
772 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("contents"), Access::Read);
773 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None);
774 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write);
775 // What reads but grants nothing is said.
776 let odd = parse("on: push\npermissions: { id-token: write, wiki: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
777 assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued");
778 assert!(odd.notes.iter().any(|n| n.message.contains("`permissions.wiki`")));
779 assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all"));
780 assert!(
781 parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]")
782 .unwrap_err()
783 .contains("Job `a`")
784 );
785 }
786
787 #[test]
788 fn a_job_has_its_own_concurrency() {
789 let workflow = parse(
790 "on: push
791jobs:
792 deploy:
793 runs-on: ubuntu-latest
794 concurrency:
795 group: deploy-${{ github.ref }}
796 cancel-in-progress: true
797 steps: [{ run: 'true' }]
798 named:
799 runs-on: ubuntu-latest
800 concurrency: just-one
801 steps: [{ run: 'true' }]",
802 )
803 .unwrap();
804 let deploy = workflow.jobs[0].concurrency.as_ref().unwrap();
805 assert_eq!(deploy.group, "deploy-${{ github.ref }}");
806 assert_eq!(deploy.cancel_in_progress, Value::Bool(true));
807 assert_eq!(workflow.jobs[1].concurrency.as_ref().unwrap().group, "just-one");
808 assert!(workflow.concurrency.is_none());
809 }
810
811 #[test]
812 fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() {
813 let workflow = parse("on: [create, delete, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
814 let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
815 assert_eq!(unsupported.len(), 1, "{unsupported:?}");
816 assert!(unsupported[0].contains("`delete`"));
817 let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap();
818 assert!(target.message.contains("default branch"));
819 assert!(!target.message.contains("on the pull request's head"));
820 }
821
822 #[test]
Social cards for every page: og.g1t.sh823 fn mistakes_are_explained() {
824 let problem = |yaml: &str| parse(yaml).unwrap_err();
825 assert!(problem("jobs: {}").contains("`on` is missing"));
826 assert!(problem("on: push").contains("`jobs` is missing"));
827 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
828 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
829 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
830 assert!(
831 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 }]")
832 .contains("circle")
833 );
834 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
835 assert!(problem(": : :").contains("not valid YAML"));
836 }
837}

This file's history is long; its oldest lines are credited to the oldest commit read.