Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,187 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.

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

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