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.

The statement is a month at a time, a line per kind of charge1//! 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.10use crate::syntax;
The statement is a month at a time, a line per kind of charge11
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.
The statement is a month at a time, a line per kind of charge17pub 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.34 "delete",
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts35 "release",
36 "deployment",
37 "deployment_status",
The statement is a month at a time, a line per kind of charge38];
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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.43 "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
The statement is a month at a time, a line per kind of charge47/// 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.119 /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys.
120 pub g1t: Option<Value>,
The statement is a month at a time, a line per kind of charge121 /// 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>,
The statement is a month at a time, a line per kind of charge157 pub steps: Vec<Step>,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.158 /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys.
159 pub g1t: Option<Value>,
The statement is a month at a time, a line per kind of charge160 /// 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
The statement is a month at a time, a line per kind of charge175#[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>,
The statement is a month at a time, a line per kind of charge184 pub jobs: Vec<Job>,
185 pub notes: Vec<Note>,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.186 /// Its top-level `g1t` settings, as written: g1t's own, beside
187 /// GitHub's keys.
188 pub g1t: Option<Value>,
The statement is a month at a time, a line per kind of charge189 /// 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.314///
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.
The statement is a month at a time, a line per kind of charge318pub 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.320 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 }
The statement is a month at a time, a line per kind of charge323 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.329 check_keys(root, syntax::WORKFLOW, true, "", "the workflow")?;
The statement is a month at a time, a line per kind of charge330
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.331 // `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."));
The statement is a month at a time, a line per kind of charge334 let mut triggers = Vec::new();
335 match on {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.336 Value::String(event) => {
337 known(event)?;
338 triggers.push(trigger(event, &Value::Null));
339 }
The statement is a month at a time, a line per kind of charge340 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.343 known(event)?;
The statement is a month at a time, a line per kind of charge344 triggers.push(trigger(event, &Value::Null));
345 }
346 }
347 Value::Object(events) => {
348 for (event, spec) in events {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.349 check_event(event, known(event)?, spec, &mut note)?;
The statement is a month at a time, a line per kind of charge350 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()) {
The statement is a month at a time, a line per kind of charge359 note(
360 Severity::Unsupported,
361 None,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.362 format!("g1t does not send GitHub's `{}` event, so that trigger never starts it.", trigger.event),
The statement is a month at a time, a line per kind of charge363 );
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(),
The statement is a month at a time, a line per kind of charge370 );
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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.381 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.391 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 }
The statement is a month at a time, a line per kind of charge398 };
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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.407 let mut seen_ids: Vec<String> = Vec::new();
The statement is a month at a time, a line per kind of charge408 for (id, spec) in job_specs {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.409 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);
The statement is a month at a time, a line per kind of charge418 let Value::Object(spec) = spec else {
419 return Err(format!("Job `{id}` is a mapping."));
420 };
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.421 let place = format!("Job `{id}`");
The statement is a month at a time, a line per kind of charge422 let uses = text(spec.get("uses"));
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.423 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)?;
The statement is a month at a time, a line per kind of charge445 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"));
The statement is a month at a time, a line per kind of charge457 let mut steps = Vec::new();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.458 let mut step_ids: Vec<String> = Vec::new();
The statement is a month at a time, a line per kind of charge459 for (index, step) in steps_raw.iter().enumerate() {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.460 let place = format!("Job `{id}`, step {}", index + 1);
The statement is a month at a time, a line per kind of charge461 let Value::Object(fields) = step else {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.462 return Err(format!("{place}: a step is a mapping."));
The statement is a month at a time, a line per kind of charge463 };
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.464 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 }
The statement is a month at a time, a line per kind of charge469 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.475 g1t: fields.get(syntax::EXTENSION).cloned(),
The statement is a month at a time, a line per kind of charge476 raw: step.clone(),
477 };
478 match (&step.uses, &step.run) {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.479 (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);
The statement is a month at a time, a line per kind of charge493 }
494 if let Some(uses) = &step.uses
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.495 && let Some((severity, message)) = action_note(uses)
The statement is a month at a time, a line per kind of charge496 {
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
The statement is a month at a time, a line per kind of charge501 && 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 );
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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 }
The statement is a month at a time, a line per kind of charge529 }
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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 }
The statement is a month at a time, a line per kind of charge539 }
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 );
The statement is a month at a time, a line per kind of charge546 }
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 );
The statement is a month at a time, a line per kind of charge553 }
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());
The statement is a month at a time, a line per kind of charge556 }
Merge branch 'worktree-agent-a3abfcce648e87dca'557 let job_permissions = match spec.get("permissions") {
558 None => None,
559 Some(value) => {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.560 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 };
The statement is a month at a time, a line per kind of charge568 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("./")) {
The statement is a month at a time, a line per kind of charge577 note(
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts578 Severity::Info,
The statement is a month at a time, a line per kind of charge579 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 ),
The statement is a month at a time, a line per kind of charge583 );
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")),
The statement is a month at a time, a line per kind of charge597 steps,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.598 g1t: spec.get(syntax::EXTENSION).cloned(),
The statement is a month at a time, a line per kind of charge599 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,
The statement is a month at a time, a line per kind of charge616 jobs,
617 notes,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.618 g1t: root.get(syntax::EXTENSION).cloned(),
The statement is a month at a time, a line per kind of charge619 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.627/// 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
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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
865/// 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.869 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
The statement is a month at a time, a line per kind of charge873/// What to say about an action g1t runs differently, if anything.
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.874fn action_note(uses: &str) -> Option<(Severity, String)> {
The statement is a month at a time, a line per kind of charge875 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).")));
The statement is a month at a time, a line per kind of charge877 }
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."),
The statement is a month at a time, a line per kind of charge884 )),
885 "actions/upload-artifact" | "actions/download-artifact" => Some((
886 Severity::Info,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.887 format!("`{name}`: g1t keeps artifacts with the run for 14 days unless the repository says otherwise, up to 5 GiB each."),
The statement is a month at a time, a line per kind of charge888 )),
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",
The statement is a month at a time, a line per kind of charge980 )
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")));
The statement is a month at a time, a line per kind of charge990 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`")));
The statement is a month at a time, a line per kind of charge995 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
996 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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]
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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"));
1013 assert!(!workflow.notes.iter().any(|n| n.job.as_deref() == Some("call") && n.severity == Severity::Warning));
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")));
The statement is a month at a time, a line per kind of charge1038 }
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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1077 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1079 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1120 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1123 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]
The statement is a month at a time, a line per kind of charge1130 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1144
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 }
The statement is a month at a time, a line per kind of charge1189}

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