Skip to content
1,189 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.

Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1//! 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;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put11
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 branch 'mirroring' into artifacts-mode14/// g1t reads `.github` only for a mirror of a GitHub repository in CI
15/// failover or taken over (services/actions/src/mirrored.rs); otherwise it
16/// stays GitHub's.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put17pub const FOLDER: &str = ".g1t/workflows";
18
19/// The events a workflow can name that g1t starts runs for.
20pub const SUPPORTED_EVENTS: &[&str] = &[
21 "push",
22 "pull_request",
23 "pull_request_target",
24 "pull_request_review",
25 "issues",
26 "issue_comment",
27 "schedule",
28 "workflow_dispatch",
29 "repository_dispatch",
30 "workflow_call",
31 "workflow_run",
32 "merge_group",
33 "create",
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r934 "delete",
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts35 "release",
36 "deployment",
37 "deployment_status",
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put38];
39
Merge branch 'worktree-agent-a3abfcce648e87dca'40/// Events GitHub has that g1t knows of but never sends: a workflow on one
41/// of them is told so, rather than waiting for a run that never comes.
42pub const UNSENT_EVENTS: &[(&str, &str)] = &[(
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r943 "label",
44 "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'45)];
46
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put47/// The `types` each event has when a workflow gives none, as on GitHub.
48pub fn default_types(event: &str) -> &'static [&'static str] {
49 match event {
50 "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"],
51 "merge_group" => &["checks_requested"],
52 _ => &[],
53 }
54}
55
56/// How much a note matters.
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum Severity {
60 /// Runs, slightly differently.
61 Info,
62 /// Runs, but something in it does nothing or may not work.
63 Warning,
64 /// Does not run on g1t.
65 Unsupported,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Note {
70 pub severity: Severity,
71 /// The job, if the note is about one.
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub job: Option<String>,
74 pub message: String,
75}
76
77/// One event a workflow is started by, with its filters.
78#[derive(Clone, Debug, Default, PartialEq, Eq)]
79pub struct Trigger {
80 pub event: String,
81 /// Activity types; empty means the event's defaults (or all).
82 pub types: Vec<String>,
83 pub branches: Filter,
84 pub tags: Filter,
85 pub paths: Filter,
86 /// For `schedule`.
87 pub crons: Vec<String>,
88 /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
89 pub inputs: Map<String, Value>,
90 /// For `workflow_run`: the names of the workflows it follows.
91 pub workflows: Vec<String>,
92}
93
94impl Trigger {
95 /// Whether an activity type starts it.
96 pub fn wants_type(&self, action: Option<&str>) -> bool {
97 let Some(action) = action else { return true };
98 if self.types.is_empty() {
99 // A g1t agent's pull request has no code until it is marked
100 // ready, so that is when its default runs start, as `opened`
101 // would on GitHub.
102 if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" {
103 return true;
104 }
105 let defaults = default_types(&self.event);
106 return defaults.is_empty() || defaults.contains(&action);
107 }
108 self.types.iter().any(|t| t == action)
109 }
110}
111
112#[derive(Clone, Debug, PartialEq)]
113pub struct Step {
114 pub id: Option<String>,
115 pub name: Option<String>,
116 pub condition: Option<String>,
117 pub uses: Option<String>,
118 pub run: Option<String>,
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9119 /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys.
120 pub g1t: Option<Value>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put121 /// The whole step as written, for the sandbox.
122 pub raw: Value,
123}
124
125impl Step {
126 /// How the step is shown when it has no name.
127 pub fn title(&self) -> String {
128 if let Some(name) = &self.name {
129 return name.clone();
130 }
131 if let Some(uses) = &self.uses {
132 return format!("Run {uses}");
133 }
134 let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
135 format!("Run {}", first.trim())
136 }
137}
138
139#[derive(Clone, Debug, PartialEq)]
140pub struct Job {
141 /// Its key under `jobs:`.
142 pub id: String,
143 pub name: Option<String>,
144 pub needs: Vec<String>,
145 pub condition: Option<String>,
146 pub runs_on: Value,
147 /// `strategy.matrix`, as written (it may be an expression).
148 pub matrix: Option<Value>,
149 pub fail_fast: bool,
150 pub max_parallel: Option<u32>,
151 /// A reusable workflow it calls (`uses:` on a job).
152 pub uses: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'153 /// Its own `permissions`, which replace the workflow's.
154 pub permissions: Option<Permissions>,
155 /// Its own `concurrency`: at most one job of its group runs at a time.
156 pub concurrency: Option<Concurrency>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put157 pub steps: Vec<Step>,
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9158 /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys.
159 pub g1t: Option<Value>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put160 /// The whole job as written, for the sandbox.
161 pub raw: Value,
162}
163
Merge branch 'worktree-agent-a3abfcce648e87dca'164impl Job {
165 /// What its token may do: its own `permissions`, else its workflow's,
166 /// else `default` (the repository's choice).
167 pub fn permissions(&self, workflow: &Workflow, default: permissions::TokenDefault) -> Permissions {
168 self.permissions
169 .clone()
170 .or_else(|| workflow.permissions.clone())
171 .unwrap_or_else(|| Permissions::default_for(default))
172 }
173}
174
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put175#[derive(Clone, Debug, PartialEq)]
176pub struct Workflow {
177 pub name: Option<String>,
178 pub run_name: Option<String>,
179 pub triggers: Vec<Trigger>,
180 pub env: Map<String, Value>,
181 pub concurrency: Option<Concurrency>,
Merge branch 'worktree-agent-a3abfcce648e87dca'182 /// Its top-level `permissions`, for every job that writes none.
183 pub permissions: Option<Permissions>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put184 pub jobs: Vec<Job>,
185 pub notes: Vec<Note>,
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9186 /// Its top-level `g1t` settings, as written: g1t's own, beside
187 /// GitHub's keys.
188 pub g1t: Option<Value>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put189 /// The whole workflow as written.
190 pub raw: Value,
191}
192
193#[derive(Clone, Debug, PartialEq, Eq)]
194pub struct Concurrency {
195 /// May hold an expression.
196 pub group: String,
197 pub cancel_in_progress: Value,
198}
199
200impl Workflow {
201 pub fn trigger(&self, event: &str) -> Option<&Trigger> {
202 self.triggers.iter().find(|trigger| trigger.event == event)
203 }
204
205 /// The name shown for it: its `name`, or its file's path.
206 pub fn display_name(&self, path: &str) -> String {
207 self.name.clone().unwrap_or_else(|| path.to_owned())
208 }
209
210 /// The job ids in an order where each comes after the jobs it needs.
211 pub fn job_order(&self) -> Vec<&str> {
212 let mut ordered: Vec<&str> = Vec::new();
213 while ordered.len() < self.jobs.len() {
214 let before = ordered.len();
215 for job in &self.jobs {
216 if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
217 ordered.push(&job.id);
218 }
219 }
220 if ordered.len() == before {
221 break;
222 }
223 }
224 ordered
225 }
226}
227
228/// YAML to JSON, keeping the order of keys. Keys that are not strings
229/// (`on: true` in YAML 1.1, numbers) become their text.
230pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
231 match value {
232 serde_yaml::Value::Null => Value::Null,
233 serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
234 serde_yaml::Value::Number(number) => {
235 if let Some(n) = number.as_i64() {
236 Value::from(n)
237 } else if let Some(n) = number.as_u64() {
238 Value::from(n)
239 } else {
240 number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
241 }
242 }
243 serde_yaml::Value::String(text) => Value::String(text.clone()),
244 serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
245 serde_yaml::Value::Mapping(map) => {
246 let mut out = Map::new();
247 for (key, value) in map {
248 let key = match key {
249 serde_yaml::Value::String(text) => text.clone(),
250 serde_yaml::Value::Bool(flag) => flag.to_string(),
251 serde_yaml::Value::Number(number) => number.to_string(),
252 _ => continue,
253 };
254 out.insert(key, yaml_to_json(value));
255 }
256 Value::Object(out)
257 }
258 serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
259 }
260}
261
262fn texts(value: Option<&Value>) -> Vec<String> {
263 match value {
264 Some(Value::String(text)) => vec![text.clone()],
265 Some(Value::Array(items)) => items
266 .iter()
267 .filter_map(|item| match item {
268 Value::String(text) => Some(text.clone()),
269 Value::Number(n) => Some(n.to_string()),
270 _ => None,
271 })
272 .collect(),
273 _ => Vec::new(),
274 }
275}
276
277fn text(value: Option<&Value>) -> Option<String> {
278 match value? {
279 Value::String(text) => Some(text.clone()),
280 Value::Number(n) => Some(n.to_string()),
281 Value::Bool(flag) => Some(flag.to_string()),
282 _ => None,
283 }
284}
285
286fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
287 let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
288 Filter { only: list(only), ignore: list(ignore) }
289}
290
291fn trigger(event: &str, spec: &Value) -> Trigger {
292 let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
293 match spec {
294 Value::Object(spec) => {
295 trigger.types = texts(spec.get("types"));
296 trigger.branches = filter(spec, "branches", "branches-ignore");
297 trigger.tags = filter(spec, "tags", "tags-ignore");
298 trigger.paths = filter(spec, "paths", "paths-ignore");
299 if let Some(Value::Object(inputs)) = spec.get("inputs") {
300 trigger.inputs = inputs.clone();
301 }
302 trigger.workflows = texts(spec.get("workflows"));
303 }
304 Value::Array(entries) if event == "schedule" => {
305 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
306 }
307 _ => {}
308 }
309 trigger
310}
311
312/// Reads a workflow. `Err` is what is wrong with the file, for the person
313/// who wrote it; what reads but runs differently is in `notes`.
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9314///
315/// It accepts what GitHub accepts and refuses what GitHub refuses: every
316/// mapping has only the keys GitHub's schema gives it ([`syntax`]), plus
317/// `g1t`, g1t's own settings, on the workflow, a job and a step.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put318pub fn parse(source: &str) -> Result<Workflow, String> {
319 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-7nm8r9320 if has_merge_key(&yaml) {
321 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());
322 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put323 let raw = yaml_to_json(&yaml);
324 let Value::Object(root) = &raw else {
325 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
326 };
327 let mut notes = Vec::new();
328 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-7nm8r9329 check_keys(root, syntax::WORKFLOW, true, "", "the workflow")?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put330
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9331 // `on`, in any of its three shapes.
332 let on = root.get("on").ok_or("`on` is missing: say which events start the workflow.")?;
333 let known = |event: &str| syntax::event(event).ok_or_else(|| format!("`on`: GitHub has no `{event}` event, so neither does g1t."));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put334 let mut triggers = Vec::new();
335 match on {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9336 Value::String(event) => {
337 known(event)?;
338 triggers.push(trigger(event, &Value::Null));
339 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put340 Value::Array(events) => {
341 for event in events {
342 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9343 known(event)?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put344 triggers.push(trigger(event, &Value::Null));
345 }
346 }
347 Value::Object(events) => {
348 for (event, spec) in events {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9349 check_event(event, known(event)?, spec, &mut note)?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put350 triggers.push(trigger(event, spec));
351 }
352 }
353 _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
354 }
355 for trigger in &triggers {
Merge branch 'worktree-agent-a3abfcce648e87dca'356 if let Some((_, why)) = UNSENT_EVENTS.iter().find(|(event, _)| *event == trigger.event) {
357 note(Severity::Unsupported, None, (*why).to_owned());
358 } else if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put359 note(
360 Severity::Unsupported,
361 None,
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9362 format!("g1t does not send GitHub's `{}` event, so that trigger never starts it.", trigger.event),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put363 );
364 }
365 if trigger.event == "pull_request_target" {
366 note(
367 Severity::Info,
368 None,
Merge branch 'worktree-agent-a3abfcce648e87dca'369 "`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(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put370 );
371 }
372 if trigger.event == "workflow_call" && triggers.len() == 1 {
373 note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
374 }
375 }
376
377 let env = match root.get("env") {
378 Some(Value::Object(env)) => env.clone(),
379 _ => Map::new(),
380 };
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9381 if let Some(defaults) = root.get("defaults") {
382 check_defaults(defaults, "")?;
383 }
384 if let Some(concurrency) = root.get("concurrency") {
385 check_concurrency(concurrency, "", None, &mut note)?;
386 }
Merge branch 'worktree-agent-a3abfcce648e87dca'387 let concurrency = concurrency_of(root.get("concurrency"));
388 let permissions = match root.get("permissions") {
389 None => None,
390 Some(value) => {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9391 if let Value::Object(map) = value {
392 check_keys(map, syntax::PERMISSIONS, false, "", "`permissions`")?;
393 }
Merge branch 'worktree-agent-a3abfcce648e87dca'394 let (permissions, unknown) = permissions::parse(value)?;
395 permission_notes(&unknown, None, &mut note);
396 Some(permissions)
397 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put398 };
399
400 let Some(Value::Object(job_specs)) = root.get("jobs") else {
401 return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
402 };
403 if job_specs.is_empty() {
404 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
405 }
406 let mut jobs = Vec::new();
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9407 let mut seen_ids: Vec<String> = Vec::new();
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put408 for (id, spec) in job_specs {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9409 if !syntax::valid_id(id) {
410 return Err(format!("`{id}` is not a job id GitHub accepts: {ID_RULE}"));
411 }
412 let lower = id.to_ascii_lowercase();
413 if let Some(index) = seen_ids.iter().position(|seen| *seen == lower) {
414 let other = job_specs.keys().nth(index).cloned().unwrap_or_default();
415 return Err(format!("Job `{id}` is already defined as `{other}`: GitHub compares job ids without case."));
416 }
417 seen_ids.push(lower);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put418 let Value::Object(spec) = spec else {
419 return Err(format!("Job `{id}` is a mapping."));
420 };
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9421 let place = format!("Job `{id}`");
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put422 let uses = text(spec.get("uses"));
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9423 if spec.contains_key("uses") {
424 for key in ["runs-on", "steps"] {
425 if spec.contains_key(key) {
426 return Err(format!("Job `{id}` calls a reusable workflow with `uses`, so it has no `{key}`: the called workflow's jobs say that."));
427 }
428 }
429 check_keys(spec, syntax::CALLER_JOB, true, &place, "a job that calls a reusable workflow")?;
430 if uses.as_deref().is_none_or(|uses| uses.trim().is_empty()) {
431 return Err(format!("Job `{id}`: `uses` names the reusable workflow it calls."));
432 }
433 match spec.get("secrets") {
434 None | Some(Value::Object(_)) => {}
435 Some(Value::String(inherit)) if inherit == "inherit" => {}
436 Some(_) => return Err(format!("Job `{id}`: `secrets` is a mapping of the secrets it passes, or `inherit`.")),
437 }
438 } else {
439 check_keys(spec, syntax::JOB, true, &place, "a job")?;
440 if !spec.contains_key("runs-on") {
441 return Err(format!("Job `{id}` has no `runs-on`: say which runner it runs on, such as `runs-on: ubuntu-latest`."));
442 }
443 }
444 check_job_parts(id, spec, &mut note)?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put445 let steps_raw = match spec.get("steps") {
446 Some(Value::Array(steps)) => steps.clone(),
447 None if uses.is_some() => Vec::new(),
448 None => return Err(format!("Job `{id}` has no `steps`.")),
449 Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
450 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily451 let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
452 let labels: Vec<String> =
453 texts(Some(&runs_on)).into_iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default()).collect();
454 // `self-hosted`, or a runner group, sends the job to the workspace's
455 // own runners, which may be Linux, macOS or Windows.
456 let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted"));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put457 let mut steps = Vec::new();
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9458 let mut step_ids: Vec<String> = Vec::new();
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put459 for (index, step) in steps_raw.iter().enumerate() {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9460 let place = format!("Job `{id}`, step {}", index + 1);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put461 let Value::Object(fields) = step else {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9462 return Err(format!("{place}: a step is a mapping."));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put463 };
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9464 if let Some(key) = syntax::BACKGROUND_STEPS.iter().find(|key| fields.contains_key(**key)) {
465 return Err(format!(
466 "{place}: `{key}` is one of GitHub's background steps (`background`, `wait`, `wait-all`, `cancel`, `parallel`), which g1t does not run yet."
467 ));
468 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put469 let step = Step {
470 id: text(fields.get("id")),
471 name: text(fields.get("name")),
472 condition: text(fields.get("if")),
473 uses: text(fields.get("uses")),
474 run: text(fields.get("run")),
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9475 g1t: fields.get(syntax::EXTENSION).cloned(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put476 raw: step.clone(),
477 };
478 match (&step.uses, &step.run) {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9479 (Some(_), Some(_)) => return Err(format!("{place}: a step has `uses` or `run`, not both.")),
480 (None, None) => return Err(format!("{place}: a step needs `uses` or `run`.")),
481 (Some(_), None) => check_keys(fields, syntax::ACTION_STEP, true, &place, "a step that uses an action")?,
482 (None, Some(_)) => check_keys(fields, syntax::RUN_STEP, true, &place, "a step that runs a command")?,
483 }
484 if let Some(step_id) = &step.id {
485 if !syntax::valid_id(step_id) {
486 return Err(format!("{place}: `{step_id}` is not a step id GitHub accepts: {ID_RULE}"));
487 }
488 let lower = step_id.to_ascii_lowercase();
489 if step_ids.contains(&lower) {
490 return Err(format!("{place}: another step of job `{id}` has the id `{step_id}` already (GitHub compares step ids without case)."));
491 }
492 step_ids.push(lower);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put493 }
494 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.495 && let Some((severity, message)) = action_note(uses)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put496 {
497 note(severity, Some(id), message);
498 }
499 if let Some(shell) = text(fields.get("shell"))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily500 && !self_hosted
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put501 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
502 {
503 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
504 }
505 steps.push(step);
506 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily507 if self_hosted {
508 note(
509 Severity::Info,
510 Some(id),
511 "`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(),
512 );
513 } else {
514 for label in &labels {
515 let lower = label.to_ascii_lowercase();
516 if lower.contains("windows") || lower.contains("macos") {
517 note(
518 Severity::Unsupported,
519 Some(id),
520 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, ...]`."),
521 );
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.522 } else if lower.contains("arm") {
523 note(
524 Severity::Warning,
525 Some(id),
526 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]`."),
527 );
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily528 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put529 }
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.530 if let Some(minutes) = spec.get("timeout-minutes").and_then(Value::as_f64)
531 && minutes > f64::from(HOSTED_MAX_TIMEOUT_MINUTES)
532 {
533 note(
534 Severity::Warning,
535 Some(id),
536 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."),
537 );
538 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put539 }
540 if spec.contains_key("services") {
Merge branch 'main' into actions-toolkit-oidc-artifacts541 note(
542 Severity::Info,
543 Some(id),
544 "`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(),
545 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put546 }
547 if spec.contains_key("container") {
Merge branch 'main' into actions-toolkit-oidc-artifacts548 note(
549 Severity::Info,
550 Some(id),
551 "`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(),
552 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put553 }
554 if spec.contains_key("environment") {
Merge branch 'worktree-agent-a3abfcce648e87dca'555 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());
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put556 }
Merge branch 'worktree-agent-a3abfcce648e87dca'557 let job_permissions = match spec.get("permissions") {
558 None => None,
559 Some(value) => {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9560 if let Value::Object(map) = value {
561 check_keys(map, syntax::PERMISSIONS, false, &place, "`permissions`")?;
562 }
Merge branch 'worktree-agent-a3abfcce648e87dca'563 let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?;
564 permission_notes(&unknown, Some(id), &mut note);
565 Some(permissions)
566 }
567 };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put568 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
569 Some(Value::Object(strategy)) => (
570 strategy.get("matrix").cloned(),
571 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
572 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
573 ),
574 _ => (None, true, None),
575 };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts576 if let Some(called) = uses.as_deref().filter(|uses| !uses.starts_with("./")) {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put577 note(
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts578 Severity::Info,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put579 Some(id),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts580 format!(
581 "`{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."
582 ),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put583 );
584 }
585 jobs.push(Job {
586 id: id.clone(),
587 name: text(spec.get("name")),
588 needs: texts(spec.get("needs")),
589 condition: text(spec.get("if")),
590 runs_on,
591 matrix,
592 fail_fast,
593 max_parallel,
594 uses,
Merge branch 'worktree-agent-a3abfcce648e87dca'595 permissions: job_permissions,
596 concurrency: concurrency_of(spec.get("concurrency")),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put597 steps,
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9598 g1t: spec.get(syntax::EXTENSION).cloned(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put599 raw: Value::Object(spec.clone()),
600 });
601 }
602 for job in &jobs {
603 for need in &job.needs {
604 if !jobs.iter().any(|other| &other.id == need) {
605 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
606 }
607 }
608 }
609 let workflow = Workflow {
610 name: text(root.get("name")),
611 run_name: text(root.get("run-name")),
612 triggers,
613 env,
614 concurrency,
Merge branch 'worktree-agent-a3abfcce648e87dca'615 permissions,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put616 jobs,
617 notes,
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9618 g1t: root.get(syntax::EXTENSION).cloned(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put619 raw,
620 };
621 if workflow.job_order().len() < workflow.jobs.len() {
622 return Err("The jobs' `needs` go round in a circle.".to_owned());
623 }
624 Ok(workflow)
625}
626
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9627/// Whether a job (as written) records a deployment to its environment:
628/// unless it says `deployment: false`, in its `environment` as on GitHub,
629/// or in its `g1t` settings.
630pub fn records_deployment(job: &Value) -> bool {
631 let off = |value: Option<&Value>| match value {
632 Some(Value::Bool(flag)) => !flag,
633 Some(Value::String(text)) => text.trim() == "false",
634 _ => false,
635 };
636 !off(job.get("environment").and_then(|environment| environment.get("deployment")))
637 && !off(job.get(syntax::EXTENSION).and_then(|settings| settings.get("deployment")))
638}
639
640/// GitHub's rule for job and step ids, as said when one breaks it.
641const ID_RULE: &str = "it starts with a letter or `_`, has only letters, digits, `-` and `_`, is under 100 characters, and does not start with `__`.";
642
643/// Whether a YAML merge key (`<<`) is anywhere in it.
644fn has_merge_key(value: &serde_yaml::Value) -> bool {
645 match value {
646 serde_yaml::Value::Mapping(map) => map.iter().any(|(key, value)| key.as_str() == Some("<<") || has_merge_key(value)),
647 serde_yaml::Value::Sequence(items) => items.iter().any(has_merge_key),
648 serde_yaml::Value::Tagged(tagged) => has_merge_key(&tagged.value),
649 _ => false,
650 }
651}
652
653/// Keys as a sentence: `a`, `b` or `c`.
654fn listed(keys: &[&str]) -> String {
655 let quoted: Vec<String> = keys.iter().map(|key| format!("`{key}`")).collect();
656 match quoted.split_last() {
657 None => String::new(),
658 Some((last, [])) => last.clone(),
659 Some((last, rest)) => format!("{} or {last}", rest.join(", ")),
660 }
661}
662
663/// `message`, said of `place` ("Job `build`, step 2"), or on its own for
664/// the workflow's top level.
665fn at(place: &str, message: &str) -> String {
666 if place.is_empty() {
667 let mut chars = message.chars();
668 chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
669 } else {
670 format!("{place}: {message}")
671 }
672}
673
674/// Refuses any key of `map` that is not `allowed`, as GitHub does
675/// ("Unexpected value"). With `extension`, `g1t` is allowed too, as a
676/// mapping of anything. `what` names the mapping in the message.
677fn check_keys(map: &Map<String, Value>, allowed: &[&str], extension: bool, place: &str, what: &str) -> Result<(), String> {
678 for (key, value) in map {
679 if extension && key == syntax::EXTENSION {
680 if !value.is_object() {
681 return Err(at(place, "`g1t` is a mapping of g1t's own settings."));
682 }
683 continue;
684 }
685 if !allowed.contains(&key.as_str()) {
686 return Err(at(place, &format!("unexpected `{key}` in {what}; it can have {}.", listed(allowed))));
687 }
688 }
689 Ok(())
690}
691
692/// Refuses a mapping without `key`.
693fn require(map: &Map<String, Value>, key: &str, place: &str, what: &str) -> Result<(), String> {
694 if map.contains_key(key) { Ok(()) } else { Err(at(place, &format!("{what} needs `{key}`."))) }
695}
696
697/// One event under `on` and what it is given.
698fn check_event(event: &str, kind: syntax::EventSpec, spec: &Value, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
699 let what = format!("`on.{event}`");
700 match (kind, spec) {
701 (syntax::EventSpec::Schedule, Value::Array(entries)) => {
702 for entry in entries {
703 let Value::Object(entry) = entry else { return Err("`on.schedule` is a list of `cron:` entries.".to_owned()) };
704 check_keys(entry, syntax::CRON, false, "", "an entry of `on.schedule`")?;
705 require(entry, "cron", "", "Each entry of `on.schedule`")?;
706 if entry.contains_key("timezone") {
707 note(
708 Severity::Warning,
709 None,
710 "`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(),
711 );
712 }
713 }
714 Ok(())
715 }
716 (syntax::EventSpec::Schedule, _) => Err("`on.schedule` is a list of `cron:` entries.".to_owned()),
717 (_, Value::Null) => Ok(()),
718 (syntax::EventSpec::Bare, _) => Err(format!("{what} takes no settings: write `{event}:` with nothing after it.")),
719 (syntax::EventSpec::Filters(keys), Value::Object(map)) => {
720 check_keys(map, keys, false, "", &what)?;
721 for only in ["branches", "tags", "paths"] {
722 let ignore = format!("{only}-ignore");
723 if map.contains_key(only) && map.contains_key(&ignore) {
724 return Err(format!(
725 "{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 `!`."
726 ));
727 }
728 }
729 let definitions = |key: &str| -> Result<Vec<(&String, &Value)>, String> {
730 match map.get(key) {
731 None | Some(Value::Null) => Ok(Vec::new()),
732 Some(Value::Object(entries)) => Ok(entries.iter().collect()),
733 Some(_) => Err(format!("`on.{event}.{key}` is a mapping of names to their definitions.")),
734 }
735 };
736 if event == "workflow_dispatch" {
737 for (name, input) in definitions("inputs")? {
738 match input {
739 Value::Null => {}
740 Value::Object(input) => check_keys(input, syntax::DISPATCH_INPUT, false, "", &format!("input `{name}` of {what}"))?,
741 _ => return Err(format!("Input `{name}` of {what} is a mapping.")),
742 }
743 }
744 }
745 if event == "workflow_call" {
746 for (name, input) in definitions("inputs")? {
747 let Value::Object(input) = input else { return Err(format!("Input `{name}` of {what} is a mapping with its `type`.")) };
748 let place = format!("input `{name}` of {what}");
749 check_keys(input, syntax::CALL_INPUT, false, "", &place)?;
750 require(input, "type", "", &format!("Input `{name}` of {what}"))?;
751 }
752 for (name, output) in definitions("outputs")? {
753 let Value::Object(output) = output else { return Err(format!("Output `{name}` of {what} is a mapping with its `value`.")) };
754 check_keys(output, syntax::CALL_OUTPUT, false, "", &format!("output `{name}` of {what}"))?;
755 require(output, "value", "", &format!("Output `{name}` of {what}"))?;
756 }
757 for (name, secret) in definitions("secrets")? {
758 match secret {
759 Value::Null => {}
760 Value::Object(secret) => check_keys(secret, syntax::CALL_SECRET, false, "", &format!("secret `{name}` of {what}"))?,
761 _ => return Err(format!("Secret `{name}` of {what} is a mapping, or nothing.")),
762 }
763 }
764 }
765 Ok(())
766 }
767 (syntax::EventSpec::Filters(_), _) => Err(format!("{what} is a mapping of its filters, or nothing.")),
768 }
769}
770
771/// `defaults`, at the top level or on a job.
772fn check_defaults(value: &Value, place: &str) -> Result<(), String> {
773 let Value::Object(defaults) = value else { return Err(at(place, "`defaults` is a mapping with `run`.")) };
774 check_keys(defaults, syntax::DEFAULTS, false, place, "`defaults`")?;
775 match defaults.get("run") {
776 None | Some(Value::Null) => Ok(()),
777 Some(Value::Object(run)) => check_keys(run, syntax::DEFAULTS_RUN, false, place, "`defaults.run`"),
778 Some(_) => Err(at(place, "`defaults.run` is a mapping with `shell` or `working-directory`.")),
779 }
780}
781
782/// `concurrency`, at the top level or on a job (`job`).
783fn check_concurrency(value: &Value, place: &str, job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
784 let Value::Object(concurrency) = value else { return Ok(()) };
785 check_keys(concurrency, syntax::CONCURRENCY, false, place, "`concurrency`")?;
786 require(concurrency, "group", place, "`concurrency`")?;
787 if let Some(queue) = concurrency.get("queue")
788 && text(Some(queue)).as_deref() != Some("single")
789 {
790 note(
791 Severity::Warning,
792 job,
793 "`concurrency.queue`: g1t keeps one run or job waiting in a group, as `queue: single` does, and does not read `queue` yet.".to_owned(),
794 );
795 }
796 Ok(())
797}
798
799/// A job's mappings other than its steps: each has only GitHub's keys.
800fn check_job_parts(id: &str, spec: &Map<String, Value>, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
801 let place = format!("Job `{id}`");
802 let credentials = |container: &Map<String, Value>, what: &str| -> Result<(), String> {
803 match container.get("credentials") {
804 Some(Value::Object(credentials)) => check_keys(credentials, syntax::CREDENTIALS, false, &place, &format!("the `credentials` of {what}")),
805 _ => Ok(()),
806 }
807 };
808 if let Some(Value::Object(runs_on)) = spec.get("runs-on") {
809 check_keys(runs_on, syntax::RUNS_ON, false, &place, "`runs-on`")?;
810 }
811 if let Some(Value::Object(strategy)) = spec.get("strategy") {
812 check_keys(strategy, syntax::STRATEGY, false, &place, "`strategy`")?;
813 }
814 if let Some(Value::Object(container)) = spec.get("container") {
815 check_keys(container, syntax::CONTAINER, false, &place, "`container`")?;
816 credentials(container, "`container`")?;
817 }
818 if let Some(Value::Object(services)) = spec.get("services") {
819 for (name, service) in services {
820 if let Value::Object(service) = service {
821 let what = format!("service `{name}`");
822 check_keys(service, syntax::SERVICE, false, &place, &what)?;
823 credentials(service, &what)?;
824 }
825 }
826 }
827 if let Some(Value::Object(environment)) = spec.get("environment") {
828 check_keys(environment, syntax::ENVIRONMENT, false, &place, "`environment`")?;
829 require(environment, "name", &place, "`environment`")?;
830 }
831 if let Some(concurrency) = spec.get("concurrency") {
832 check_concurrency(concurrency, &place, Some(id), note)?;
833 }
834 if let Some(defaults) = spec.get("defaults") {
835 check_defaults(defaults, &place)?;
836 }
837 if spec.contains_key("snapshot") {
838 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());
839 }
840 if spec.contains_key("cancel-timeout-minutes") {
841 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());
842 }
843 Ok(())
844}
845
Merge branch 'worktree-agent-a3abfcce648e87dca'846/// `concurrency`, as a group's name or a mapping with `group` and
847/// `cancel-in-progress`.
848fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> {
849 match value {
850 Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
851 Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
852 group,
853 cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
854 }),
855 _ => None,
856 }
857}
858
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.859/// The longest a job on g1t's own machines may run, whatever its
860/// `timeout-minutes`.
861pub const HOSTED_MAX_TIMEOUT_MINUTES: u32 = 60;
862/// How long a job may run when it names no `timeout-minutes`, as on GitHub.
863pub const DEFAULT_TIMEOUT_MINUTES: u32 = 360;
864
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9865/// What to say about `permissions` names GitHub has that g1t's token has
866/// nothing behind.
Merge branch 'worktree-agent-a3abfcce648e87dca'867fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
868 for name in unknown {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9869 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'870 }
871}
872
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put873/// 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.874fn action_note(uses: &str) -> Option<(Severity, String)> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put875 if uses.starts_with("docker://") {
Merge branch 'main' into actions-toolkit-oidc-artifacts876 return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine).")));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put877 }
878 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
879 match name.as_str() {
880 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
881 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
882 Severity::Info,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily883 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."),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put884 )),
885 "actions/upload-artifact" | "actions/download-artifact" => Some((
886 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.887 format!("`{name}`: g1t keeps artifacts with the run for 14 days unless the repository says otherwise, up to 5 GiB each."),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put888 )),
889 _ => None,
890 }
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896
897 const CI: &str = r#"
898name: CI
899on:
900 push:
901 branches: [main]
902 paths-ignore: ["docs/**"]
903 pull_request:
904 workflow_dispatch:
905 inputs:
906 debug:
907 type: boolean
908 default: false
909 schedule:
910 - cron: "0 3 * * *"
911concurrency:
912 group: ci-${{ github.ref }}
913 cancel-in-progress: true
914env:
915 CARGO_TERM_COLOR: always
916jobs:
917 test:
918 runs-on: ${{ matrix.os }}
919 strategy:
920 matrix:
921 os: [ubuntu-latest, windows-latest]
922 node: [22, 24]
923 steps:
924 - uses: actions/checkout@v7
925 - uses: actions/setup-node@v7
926 with:
927 node-version: ${{ matrix.node }}
928 - run: npm ci
929 - name: Test
930 run: npm test
931 deploy:
932 needs: test
933 if: github.ref == 'refs/heads/main'
934 runs-on: ubuntu-latest
935 steps:
936 - run: echo deploy
937"#;
938
939 #[test]
940 fn a_whole_workflow_reads() {
941 let workflow = parse(CI).unwrap();
942 assert_eq!(workflow.name.as_deref(), Some("CI"));
943 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
944 let push = workflow.trigger("push").unwrap();
945 assert!(push.branches.allows("main"));
946 assert!(!push.branches.allows("dev"));
947 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
948 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
949 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
950 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
951 assert_eq!(workflow.jobs.len(), 2);
952 assert_eq!(workflow.jobs[1].needs, ["test"]);
953 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7");
954 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
955 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
956 assert_eq!(workflow.job_order(), ["test", "deploy"]);
957 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
958 }
959
960 #[test]
961 fn short_forms_of_on() {
962 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
963 assert_eq!(one.triggers[0].event, "push");
964 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
965 assert_eq!(list.triggers.len(), 2);
966 let pr = list.trigger("pull_request").unwrap();
967 assert!(pr.wants_type(Some("opened")));
968 assert!(pr.wants_type(Some("synchronize")));
969 assert!(!pr.wants_type(Some("closed")));
970 assert!(pr.wants_type(Some("ready_for_review")));
971 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
972 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
973 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
974 }
975
976 #[test]
977 fn notes_say_what_runs_differently() {
978 let workflow = parse(
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts979 "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",
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put980 )
981 .unwrap();
982 let unsupported: Vec<&str> =
983 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 timeouts984 assert!(unsupported.iter().any(|m| m.contains("`watch`")));
985 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();
986 assert!(!released.notes.iter().any(|n| n.severity == Severity::Unsupported));
987 assert!(released.trigger("release").unwrap().wants_type(Some("published")));
988 assert!(!released.trigger("release").unwrap().wants_type(Some("created")));
989 assert!(released.trigger("deployment_status").unwrap().wants_type(Some("created")));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put990 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
Merge branch 'main' into actions-toolkit-oidc-artifacts991 assert!(!unsupported.iter().any(|m| m.contains("services")));
992 assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
993 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost")));
994 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put995 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
996 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.997 // Toolkit caches work on g1t, so `setup-node` with `cache:` says nothing.
998 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 daily999 assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry")));
1000 }
1001
1002 #[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.1003 fn notes_say_what_g1ts_machines_change_about_a_job() {
1004 let workflow = parse(
1005 "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' }]",
1006 )
1007 .unwrap();
1008 let warned = |job: &str, text: &str| {
1009 workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.job.as_deref() == Some(job) && n.message.contains(text))
1010 };
1011 assert!(warned("arm", "runs on x64"));
1012 assert!(warned("arm", "stopped at 60 minutes"));
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91013 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.1014 assert!(!workflow.notes.iter().any(|n| matches!(n.job.as_deref(), Some("short" | "mine")) && n.severity == Severity::Warning));
1015 let artifacts = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ uses: actions/upload-artifact@v4 }]").unwrap();
1016 assert!(artifacts.notes.iter().any(|n| n.message.contains("5 GiB each")));
1017 }
1018
1019 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1020 fn self_hosted_jobs_may_run_on_any_os() {
1021 let workflow = parse(
1022 "on: push
1023jobs:
1024 win:
1025 runs-on: [self-hosted, windows]
1026 steps:
1027 - run: dir
1028 shell: pwsh
1029 mac:
1030 runs-on: { group: Macs, labels: [macos] }
1031 steps: [{ run: 'true' }]",
1032 )
1033 .unwrap();
1034 assert!(!workflow.notes.iter().any(|n| n.severity == Severity::Unsupported), "{:?}", workflow.notes);
1035 let routed: Vec<&str> = workflow.notes.iter().filter(|n| n.message.starts_with("`self-hosted`")).map(|n| n.message.as_str()).collect();
1036 assert_eq!(routed.len(), 2);
1037 assert!(routed.iter().all(|m| m.contains("self-hosted runners") && !m.contains("Linux")));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1038 }
1039
1040 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'1041 fn permissions_are_read_at_both_levels() {
1042 use crate::permissions::{Access, TokenDefault};
1043 let workflow = parse(
1044 "on: push
1045permissions:
1046 contents: read
1047 pull-requests: write
1048jobs:
1049 plain:
1050 runs-on: ubuntu-latest
1051 steps: [{ run: 'true' }]
1052 release:
1053 runs-on: ubuntu-latest
1054 permissions:
1055 contents: write
1056 steps: [{ run: 'true' }]
1057 quiet:
1058 runs-on: ubuntu-latest
1059 permissions: {}
1060 steps: [{ run: 'true' }]",
1061 )
1062 .unwrap();
1063 let plain = workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted);
1064 assert_eq!(plain.get("pull-requests"), Access::Write);
1065 assert_eq!(plain.get("contents"), Access::Read);
1066 // A job's own permissions replace the workflow's whole.
1067 let release = workflow.jobs[1].permissions(&workflow, TokenDefault::Permissive);
1068 assert_eq!(release.get("contents"), Access::Write);
1069 assert_eq!(release.get("pull-requests"), Access::None);
1070 assert_eq!(workflow.jobs[2].permissions(&workflow, TokenDefault::Permissive).scopes(), ["repo:read"]);
1071 // Without any, the repository's default.
1072 let bare = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
1073 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("contents"), Access::Read);
1074 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None);
1075 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write);
1076 // What reads but grants nothing is said.
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r91077 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'1078 assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued");
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r91079 assert!(odd.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("`permissions.artifact-metadata`")));
1080 // A name GitHub's token does not have is refused, as GitHub does;
1081 // `metadata` is always `read` and cannot be written.
1082 for name in ["wiki", "metadata"] {
1083 let problem = parse(&format!("on: push\npermissions: {{ {name}: read }}\njobs:\n a:\n runs-on: x\n steps: [{{ run: 'true' }}]")).unwrap_err();
1084 assert!(problem.contains(&format!("Unexpected `{name}` in `permissions`")), "{problem}");
1085 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1086 assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all"));
1087 assert!(
1088 parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]")
1089 .unwrap_err()
1090 .contains("Job `a`")
1091 );
1092 }
1093
1094 #[test]
1095 fn a_job_has_its_own_concurrency() {
1096 let workflow = parse(
1097 "on: push
1098jobs:
1099 deploy:
1100 runs-on: ubuntu-latest
1101 concurrency:
1102 group: deploy-${{ github.ref }}
1103 cancel-in-progress: true
1104 steps: [{ run: 'true' }]
1105 named:
1106 runs-on: ubuntu-latest
1107 concurrency: just-one
1108 steps: [{ run: 'true' }]",
1109 )
1110 .unwrap();
1111 let deploy = workflow.jobs[0].concurrency.as_ref().unwrap();
1112 assert_eq!(deploy.group, "deploy-${{ github.ref }}");
1113 assert_eq!(deploy.cancel_in_progress, Value::Bool(true));
1114 assert_eq!(workflow.jobs[1].concurrency.as_ref().unwrap().group, "just-one");
1115 assert!(workflow.concurrency.is_none());
1116 }
1117
1118 #[test]
1119 fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() {
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91120 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'1121 let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
1122 assert_eq!(unsupported.len(), 1, "{unsupported:?}");
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91123 assert!(unsupported[0].contains("`label`"));
Merge branch 'worktree-agent-a3abfcce648e87dca'1124 let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap();
1125 assert!(target.message.contains("default branch"));
1126 assert!(!target.message.contains("on the pull request's head"));
1127 }
1128
1129 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1130 fn mistakes_are_explained() {
1131 let problem = |yaml: &str| parse(yaml).unwrap_err();
1132 assert!(problem("jobs: {}").contains("`on` is missing"));
1133 assert!(problem("on: push").contains("`jobs` is missing"));
1134 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
1135 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
1136 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
1137 assert!(
1138 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 }]")
1139 .contains("circle")
1140 );
1141 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
1142 assert!(problem(": : :").contains("not valid YAML"));
1143 }
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r91144
1145 #[test]
1146 fn only_githubs_keys_and_g1ts_own() {
1147 let problem = |yaml: &str| parse(yaml).unwrap_err();
1148 let job = "\njobs:\n build:\n runs-on: x\n steps: [{ run: 'true' }]";
1149 assert!(problem(&format!("on: push\nrun_name: x{job}")).starts_with("Unexpected `run_name` in the workflow; it can have `on`, "));
1150 assert!(problem("on: push\njobs:\n build:\n runs-on: x\n step: []").starts_with("Job `build`: unexpected `step` in a job;"));
1151 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");
1152 assert!(step.starts_with("Job `build`, step 2: unexpected `shell` in a step that uses an action;"), "{step}");
1153 assert!(problem("on: push\njobs:\n build:\n steps: [{ run: 'true' }]").contains("no `runs-on`"));
1154 assert!(problem("on: push\njobs:\n build:\n uses: ./.g1t/workflows/b.yml\n runs-on: x").contains("no `runs-on`"));
1155 assert!(problem("on: push\njobs:\n my.job:\n runs-on: x\n steps: [{ run: 'true' }]").contains("`my.job` is not a job id"));
1156 assert!(problem("on: pushh\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").contains("no `pushh` event"));
1157
1158 // `g1t` is g1t's own, at each level, and kept as written.
1159 let workflow = parse(
1160 "on: push
1161g1t: { note: top }
1162jobs:
1163 deploy:
1164 runs-on: x
1165 environment: production
1166 g1t: { deployment: false }
1167 steps:
1168 - run: 'true'
1169 g1t: { anything: [1, 2] }",
1170 )
1171 .unwrap();
1172 assert_eq!(workflow.g1t, Some(serde_json::json!({ "note": "top" })));
1173 assert_eq!(workflow.jobs[0].g1t, Some(serde_json::json!({ "deployment": false })));
1174 assert_eq!(workflow.jobs[0].steps[0].g1t, Some(serde_json::json!({ "anything": [1, 2] })));
1175 assert!(problem(&format!("on: push\ng1t: yes{job}")).contains("`g1t` is a mapping"));
1176 assert!(!records_deployment(&workflow.jobs[0].raw));
1177 assert!(!records_deployment(&serde_json::json!({ "environment": { "name": "p", "deployment": false } })));
1178 assert!(records_deployment(&serde_json::json!({ "environment": { "name": "p", "deployment": true } })));
1179 assert!(records_deployment(&serde_json::json!({ "environment": "p" })));
1180 }
1181
1182 #[test]
1183 fn aliases_read_and_merge_keys_do_not() {
1184 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();
1185 assert_eq!(aliased.jobs[1].steps.len(), 1);
1186 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();
1187 assert!(merged.contains("merge key"), "{merged}");
1188 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1189}

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