Skip to content
2,850 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 balance shows what was spent this month and in total1//! A run's life: made, its jobs waiting on the jobs they need, each job
2//! skipped or expanded into its matrix and queued, started in a sandbox
3//! when its workspace has room, reporting its steps and logs as it goes,
4//! and finished; the run finishes with its last job.
5
6use g1t_actions::events::{RunInfo, runner_context};
7use g1t_actions::expr::{self, Scope, Status};
8use g1t_actions::matrix;
9use g1t_actions::workflow::{self, Workflow};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look10use g1t_contracts::access::Capability;
Merge branch 'worktree-agent-a3abfcce648e87dca'11use g1t_contracts::actions::{JobCallArgs, RunActionArgs, RunApproval, StartJobArgs, WorkflowRun};
12use g1t_contracts::identity::{CreateJobTokenArgs, CreatedAccessToken, RevokeJobTokensArgs};
The balance shows what was spent this month and in total13use g1t_contracts::repos::{Repo, RepoPath};
14use g1t_contracts::time::rfc3339;
15use g1t_contracts::{FailureCode, Outcome, new_id};
16use g1t_kit::now_ms;
17use g1t_secrets::{random_hex, same, sha256_hex};
18use serde::Deserialize;
19use serde_json::{Map, Value, json};
20use worker::Result;
21
Merge branch 'worktree-agent-a3abfcce648e87dca'22use crate::protection::Gate;
The balance shows what was spent this month and in total23use crate::sync::WorkflowRow;
Fast pages, required checks on the branch, self-hosted runners, honest incidents24use crate::{Actions, Count, MAX_TIMEOUT_MINUTES, RUNNING_PER_WORKSPACE, SELF_HOSTED_MAX_TIMEOUT_MINUTES, SILENT_MS, SITE, check, fail, optional, repo_path};
25use g1t_contracts::runners::{Wanted, waiting_reason};
The balance shows what was spent this month and in total26
27/// The most log one job keeps, in bytes; past it, the log says so and stops.
28const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
29/// The most a single log report may add.
30const MAX_CHUNK_BYTES: usize = 256 * 1024;
31const MAX_ANNOTATIONS: usize = 50;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)32/// The most one step's job summary keeps, in bytes, and how many steps of a
33/// job may have one, as on GitHub.
34pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
35pub(crate) const MAX_SUMMARIES: u32 = 20;
36
37/// Whether a step's summary of `adding` bytes is kept: a job keeps up to
38/// 20 steps' summaries (`steps` it has, `mine` this step's bytes so far)
39/// of up to 1 MiB each.
40pub(crate) fn summary_fits(steps: u32, mine: Option<usize>, adding: usize) -> bool {
41 if adding == 0 {
42 return false;
43 }
44 match mine {
45 Some(used) => used + adding <= MAX_SUMMARY_BYTES,
46 None => steps < MAX_SUMMARIES && adding <= MAX_SUMMARY_BYTES,
47 }
48}
The balance shows what was spent this month and in total49
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50/// The repository whose unfinished runs `event` stops, by id: one deleted,
51/// or archived (not unarchived).
52pub fn stops_runs(event: &g1t_contracts::events::Event) -> Option<String> {
53 let stops = match event.kind.as_str() {
54 "repo.deleted" => true,
55 "repo.archived" => event.data["archived"].as_bool() != Some(false),
56 _ => false,
57 };
58 if !stops {
59 return None;
60 }
61 event.repo_id.clone().or_else(|| event.data["repoId"].as_str().map(str::to_owned))
62}
63
The balance shows what was spent this month and in total64pub struct NewRun {
65 pub repo: Repo,
66 pub path: String,
67 pub source: String,
68 pub workflow: Workflow,
69 pub info: RunInfo,
70 pub action: Option<String>,
71 pub pull: Option<u32>,
72 pub title: String,
73 pub inputs: Map<String, Value>,
74 pub event_key: String,
75 pub actor_id: Option<String>,
76 pub actor: Option<String>,
77 pub trusted: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'78 /// Why the run waits for someone with the Write role to approve it
79 /// before anything starts: a pull request from outside (protection.rs).
80 pub approval: Option<String>,
The balance shows what was spent this month and in total81}
82
83#[derive(Clone, Deserialize)]
84pub struct RunRow {
85 pub id: String,
86 pub workflow_id: String,
87 pub repo_id: String,
88 pub repo: String,
89 pub path: String,
90 pub name: String,
91 pub title: String,
92 pub number: u64,
93 pub attempt: u64,
94 pub event: String,
95 pub action: Option<String>,
96 pub git_ref: String,
97 pub sha: String,
98 pub pull: Option<u32>,
99 pub status: String,
100 pub conclusion: Option<String>,
101 pub error: Option<String>,
102 pub actor: Option<String>,
103 pub actor_id: Option<String>,
104 pub source: String,
105 pub info: String,
106 pub inputs: String,
107 pub trusted: u32,
108 pub concurrency_group: Option<String>,
109 pub created_at: String,
110 pub started_at: Option<String>,
111 pub finished_at: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'112 /// JSON `RunApproval`, for a run that needed approval (migration 0006).
113 #[serde(default)]
114 pub approval: Option<String>,
115 /// Whether its concurrency group cancels what it replaces.
116 #[serde(default)]
117 pub cancel_in_progress: u32,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)118 /// Who started the current attempt, once it is re-run (migration 0009).
119 #[serde(default)]
120 pub triggering_actor: Option<String>,
121 /// The current attempt runs with debug logging.
122 #[serde(default)]
123 pub debug: u32,
The balance shows what was spent this month and in total124}
125
126impl RunRow {
Merge branch 'worktree-agent-a3abfcce648e87dca'127 pub fn approval(&self) -> Option<RunApproval> {
128 self.approval.as_deref().and_then(|text| serde_json::from_str(text).ok())
129 }
130
The balance shows what was spent this month and in total131 pub fn info(&self) -> RunInfo {
132 let mut info: RunInfo = serde_json::from_str(&self.info).unwrap_or_default();
133 info.run_id = self.id.clone();
134 info.run_number = self.number;
135 info.run_attempt = self.attempt;
136 info.workflow_path = self.path.clone();
137 if info.workflow.is_empty() {
138 info.workflow = self.name.clone();
139 }
140 info
141 }
142
143 pub fn inputs(&self) -> Map<String, Value> {
144 serde_json::from_str(&self.inputs).unwrap_or_default()
145 }
146
147 pub fn summary(&self) -> WorkflowRun {
148 WorkflowRun {
149 id: self.id.clone(),
150 workflow_id: self.workflow_id.clone(),
151 path: self.path.clone(),
152 name: self.name.clone(),
153 title: self.title.clone(),
154 number: self.number,
155 attempt: self.attempt,
156 event: self.event.clone(),
157 git_ref: self.git_ref.clone(),
158 sha: self.sha.clone(),
159 pull: self.pull,
160 status: self.status.clone(),
161 conclusion: self.conclusion.clone(),
162 error: self.error.clone(),
163 actor: self.actor.clone(),
164 created_at: self.created_at.clone(),
165 started_at: self.started_at.clone(),
166 finished_at: self.finished_at.clone(),
167 }
168 }
169}
170
171#[derive(Clone, Deserialize)]
172pub struct JobRow {
173 pub id: String,
174 pub run_id: String,
175 pub repo_id: String,
176 pub namespace: String,
177 pub key: String,
178 pub ordinal: u32,
179 pub name: String,
180 pub needs: String,
181 pub matrix: Option<String>,
182 pub status: String,
183 pub conclusion: Option<String>,
184 pub steps: String,
185 pub annotations: String,
186 pub outputs: String,
187 pub reason: Option<String>,
188 pub token_hash: Option<String>,
189 pub timeout_minutes: u32,
190 pub continue_on_error: u32,
191 pub max_parallel: Option<u32>,
192 /// Set for a job that calls a reusable workflow, and for that
193 /// workflow's jobs (see migration 0002).
194 pub call: Option<String>,
195 pub seen_at: Option<String>,
196 pub started_at: Option<String>,
197 pub finished_at: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents198 /// For a job whose `runs-on` names self-hosted runners: what it asks
199 /// for, as a JSON array (see `g1t_contracts::runners::Wanted`), when it
200 /// started waiting, and the runner that took it (migration 0004).
201 #[serde(default)]
202 pub labels: Option<String>,
203 #[serde(default)]
204 pub queued_at: Option<String>,
205 #[serde(default)]
206 pub runner_id: Option<String>,
207 #[serde(default)]
208 pub runner_name: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'209 /// The environment it names, read when its needs were done; a job its
210 /// rules hold is `pending` (migration 0006).
211 #[serde(default)]
212 pub environment: Option<String>,
213 /// Its own `concurrency` group, and whether that cancels what it replaces.
214 #[serde(default)]
215 pub concurrency_group: Option<String>,
216 #[serde(default)]
217 pub cancel_in_progress: u32,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)218 /// When it was told to stop, while it runs its cleanup steps
219 /// (migration 0009).
220 #[serde(default)]
221 pub cancel_requested_at: Option<String>,
222}
223
224/// How long a cancelled job has to run its `if: always()` and `cancelled()`
225/// steps and its post steps before it is stopped outright, as on GitHub.
226pub const CANCEL_GRACE_MS: u64 = 5 * 60 * 1000;
227
228/// Which jobs a re-run runs again.
229#[derive(Clone, Copy, Debug, PartialEq)]
230pub enum Rerun<'a> {
231 All,
232 /// Those that did not succeed.
233 Failed,
234 /// One job's key.
235 Job(&'a str),
236}
237
238/// The keys a re-run runs again, in `order`: those `which` names and,
239/// transitively, every key that needs one of them. `needs` gives a key's
240/// needs; `succeeded` whether every job of a key succeeded.
241pub fn rerun_keys(order: &[&str], needs: impl Fn(&str) -> Vec<String>, succeeded: impl Fn(&str) -> bool, which: Rerun) -> Vec<String> {
242 let mut again: Vec<String> = Vec::new();
243 for key in order {
244 let named = match which {
245 Rerun::All => true,
246 Rerun::Failed => !succeeded(key),
247 Rerun::Job(job) => *key == job,
248 };
249 if named || needs(key).iter().any(|need| again.contains(need)) {
250 again.push((*key).to_owned());
251 }
252 }
253 again
254}
255
256/// The key under `jobs:` a job belongs to: a called workflow's jobs
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.257/// (`build/test`, or `build[1]/test` for a matrix's second call) run again
258/// with the job that calls it (`build`).
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)259pub fn top_key(key: &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.260 let first = key.split('/').next().unwrap_or(key);
261 first.split('[').next().unwrap_or(first)
262}
263
264/// What the keys of the jobs a caller's call makes start with: the
265/// caller's key, and for a matrix the combination's index after it
266/// (`build[1]`), so each combination's call has jobs of its own. A job id
267/// has no `[`, so the two never meet.
268fn call_prefix(key: &str, index: Option<usize>) -> String {
269 match index {
270 Some(index) => format!("{key}[{index}]"),
271 None => key.to_owned(),
272 }
273}
274
275/// Whether a run may call one more reusable workflow, `file`, `depth`
276/// levels below the workflow it started from, having called `called`
277/// already (one name per call; repeats are one workflow). As on GitHub: ten
278/// levels of workflows at most, the top one and nine reusable ones below
279/// it, and 50 different reusable workflows in a run.
280fn call_allowed(depth: u64, called: &[&str], file: &str) -> std::result::Result<(), String> {
281 if depth > MAX_CALL_DEPTH {
282 return Err(format!(
283 "`{file}` would be the {}th level of workflows: a run connects at most ten, the workflow it started from and nine levels of reusable workflows below it.",
284 depth + 1
285 ));
286 }
287 let mut unique: Vec<&str> = called.to_vec();
288 unique.push(file);
289 unique.sort_unstable();
290 unique.dedup();
291 if unique.len() > MAX_CALLED_WORKFLOWS {
292 return Err(format!("Calling `{file}` would make {} reusable workflows in this run: a run calls at most {MAX_CALLED_WORKFLOWS} different ones.", unique.len()));
293 }
294 Ok(())
295}
296
297/// What a job is shown as: its `name` (else its id), under the job whose
298/// call made it (`caller`), read with its matrix when it has an
299/// expression, else with the matrix's values after it.
300fn shown_name(job: &workflow::Job, caller: Option<&str>, combination: &Map<String, Value>, scope: &Scope) -> String {
301 let base = job.name.clone().unwrap_or(job.id.clone());
302 let base = match caller {
303 Some(caller) => format!("{caller} / {base}"),
304 None => base,
305 };
306 if expr::has_expression(&base) {
307 expr::interpolate(&base, scope).unwrap_or(base)
308 } else if job.matrix.is_some() {
309 matrix::job_name(&base, combination)
310 } else {
311 base
312 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)313}
314
315/// Debug logging for a job, as a re-run with it turned on gives GitHub's:
316/// `RUNNER_DEBUG=1` and `runner.debug`, and `ACTIONS_STEP_DEBUG` and
317/// `ACTIONS_RUNNER_DEBUG` set, which the runner reads as the secrets of
318/// those names (`::debug::` lines shown, and how each step's `if` read).
319pub fn debug_logging(variables: &mut Map<String, Value>, runner: &mut Value) {
320 variables.insert("RUNNER_DEBUG".into(), json!("1"));
321 variables.insert("ACTIONS_STEP_DEBUG".into(), json!("true"));
322 variables.insert("ACTIONS_RUNNER_DEBUG".into(), json!("true"));
323 runner["debug"] = json!("1");
The balance shows what was spent this month and in total324}
325
326impl JobRow {
327 pub fn needs(&self) -> Vec<String> {
328 serde_json::from_str(&self.needs).unwrap_or_default()
329 }
330
331 pub fn call(&self) -> Option<Value> {
332 self.call.as_deref().and_then(|call| serde_json::from_str(call).ok())
333 }
334
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.335 /// For a job that calls a reusable workflow: what the keys of the jobs
336 /// its call makes start with (`call_prefix`).
337 pub fn call_prefix(&self) -> Option<String> {
338 let call = self.call().filter(|call| call["role"] == "caller")?;
339 Some(call["prefix"].as_str().unwrap_or(&self.key).to_owned())
340 }
341
342 /// A job that calls a reusable workflow and has not called it yet: it
343 /// waits for room under its matrix's `max-parallel`, or in its
344 /// concurrency group.
345 pub fn holds_call(&self) -> bool {
346 self.status == "waiting" && self.call_prefix().is_some()
347 }
348
The balance shows what was spent this month and in total349 /// For a job of a called workflow: that workflow, the job's own id in
350 /// it, and the job.
351 pub fn callee(&self) -> Option<(Workflow, workflow::Job, Value)> {
352 let call = self.call().filter(|call| call["role"] == "callee")?;
353 let called = workflow::parse(call["source"].as_str()?).ok()?;
354 let job = called.jobs.iter().find(|job| call["job"].as_str() == Some(job.id.as_str()))?.clone();
355 Some((called, job, call))
356 }
357}
358
359/// A job to decide on: its key, its definition, and what it needs, as
360/// (name in `needs`, key of the jobs).
361type Unit = (String, workflow::Job, Vec<(String, String)>);
362
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.363/// How many levels of reusable workflows may sit below the workflow a run
364/// started from, as on GitHub (ten levels of workflows in all).
365const MAX_CALL_DEPTH: u64 = 9;
366/// How many different reusable workflows a run may call, as on GitHub.
367const MAX_CALLED_WORKFLOWS: usize = 50;
The balance shows what was spent this month and in total368
369/// What the jobs of one key came to, for `needs.<key>`.
370fn key_result(rows: &[&JobRow]) -> &'static str {
371 let failed = |row: &&&JobRow| row.conclusion.as_deref() == Some("failure") && row.continue_on_error == 0;
372 if rows.iter().any(|row| failed(&row)) {
373 "failure"
374 } else if rows.iter().any(|row| row.conclusion.as_deref() == Some("cancelled")) {
375 "cancelled"
376 } else if rows.iter().all(|row| row.conclusion.as_deref() == Some("skipped")) {
377 "skipped"
378 } else {
379 "success"
380 }
381}
382
Fast pages, required checks on the branch, self-hosted runners, honest incidents383/// Whether any job before `key` failed: one it needs, or one those need,
384/// however far back. A job after a skipped one still sees the failure
385/// that skipped it, as GitHub's failure() does. `needs_of`: each key's
386/// needs, as keys; `failed`: whether a key's jobs came to a failure.
387fn ancestor_failed(needs_of: &std::collections::HashMap<&str, Vec<&str>>, key: &str, failed: impl Fn(&str) -> bool) -> bool {
388 let mut seen = std::collections::HashSet::new();
389 let mut stack: Vec<&str> = needs_of.get(key).cloned().unwrap_or_default();
390 while let Some(next) = stack.pop() {
391 if !seen.insert(next) {
392 continue;
393 }
394 if failed(next) {
395 return true;
396 }
397 stack.extend(needs_of.get(next).into_iter().flatten().copied());
398 }
399 false
400}
401
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97402/// A job's `environment:`, as deployments read it: its name, the address in
403/// `environment.url` once its expressions are filled in from the run, and
404/// whether the job deploys to it. A job with `deployment: false` only reads
405/// the environment's secrets and variables, and makes no deployment.
406#[derive(Clone, Debug, Default, PartialEq)]
407pub(crate) struct JobEnvironment {
408 pub(crate) name: String,
409 pub(crate) url: Option<String>,
410 pub(crate) deploys: bool,
411}
412
413/// The environment `environment:` names, as written in `raw` (a job), with
414/// `contexts` to fill in expressions: null when it names none, or only
415/// through an expression this cannot read before the job runs.
416pub(crate) fn environment_of(raw: &Value, contexts: &Map<String, Value>) -> Option<JobEnvironment> {
417 let scope = Scope { contexts, status: Status::Success, hash_files: None };
418 let plain = |value: &Value| -> Option<String> {
419 let text = match value {
420 Value::String(text) if text.contains("${{") => expr::interpolate_value(value, &scope).ok().map(|v| expr::to_text(&v))?,
421 Value::String(text) => text.clone(),
422 _ => return None,
423 };
424 let text = text.trim().to_owned();
425 (!text.is_empty()).then_some(text)
426 };
427 match raw.get("environment")? {
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.428 name @ Value::String(_) => Some(JobEnvironment { name: plain(name)?, url: None, deploys: workflow::records_deployment(raw) }),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97429 Value::Object(env) => {
430 let name = plain(env.get("name")?)?;
431 let url = env
432 .get("url")
433 .and_then(plain)
434 .filter(|url| url.starts_with("https://") || url.starts_with("http://"));
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.435 Some(JobEnvironment { name, url, deploys: workflow::records_deployment(raw) })
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97436 }
437 _ => None,
438 }
439}
440
441/// The contexts a job's `runs-on` and `environment` are read with before it
442/// runs: the run's `github`, its inputs, and the job's matrix.
443fn start_contexts(run: &RunRow, job: &JobRow, key: &str) -> Map<String, Value> {
444 let mut contexts = Map::new();
445 contexts.insert("github".into(), run.info().context(key, "", run.action.as_deref()));
446 contexts.insert("inputs".into(), Value::Object(run.inputs()));
447 contexts.insert("matrix".into(), job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or_else(|| json!({})));
448 contexts
449}
450
451/// The job as its workflow (or the workflow it calls) defines it.
452fn job_spec(run: &RunRow, job: &JobRow) -> Option<workflow::Job> {
453 match job.callee() {
454 Some((_, spec, _)) => Some(spec),
455 None => workflow::parse(&run.source).ok().and_then(|w| w.jobs.into_iter().find(|j| j.id == job.key)),
456 }
457}
458
Merge branch 'worktree-agent-a3abfcce648e87dca'459/// The environment a job deploys to, if it deploys: by the name read when
460/// its needs were done (which may have needed their outputs), else as
461/// its `environment:` reads now.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97462pub(crate) fn deploys_to(run: &RunRow, job: &JobRow) -> Option<JobEnvironment> {
463 let spec = job_spec(run, job)?;
Merge branch 'worktree-agent-a3abfcce648e87dca'464 let read = environment_of(&spec.raw, &start_contexts(run, job, &spec.id));
465 let env = match (read, &job.environment) {
466 (Some(env), Some(name)) => Some(JobEnvironment { name: name.clone(), ..env }),
467 (Some(env), None) => Some(env),
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.468 (None, Some(name)) => Some(JobEnvironment { name: name.clone(), url: None, deploys: workflow::records_deployment(&spec.raw) }),
Merge branch 'worktree-agent-a3abfcce648e87dca'469 (None, None) => None,
470 };
471 env.filter(|env| env.deploys)
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97472}
473
Fast pages, required checks on the branch, self-hosted runners, honest incidents474/// What the runner is told about a job it starts, from its workflow: the
475/// environment it names plainly, and the machine its `runs-on` asks for
476/// (`instance_for`; none for the standard one).
477#[derive(Default)]
478struct StartDetails {
479 environment: Option<String>,
480 instance: Option<String>,
481}
482
483fn start_details(run: &RunRow, job: &JobRow) -> StartDetails {
484 let spec = match job.callee() {
485 Some((_, spec, _)) => spec,
486 None => match workflow::parse(&run.source).ok().and_then(|w| w.jobs.into_iter().find(|j| j.id == job.key)) {
487 Some(spec) => spec,
488 None => return StartDetails::default(),
489 },
490 };
Merge branch 'worktree-agent-a3abfcce648e87dca'491 // As read when its needs were done, an expression's included.
492 let environment = job.environment.clone();
Fast pages, required checks on the branch, self-hosted runners, honest incidents493 // `runs-on` as the job was queued with: its matrix and the run's
494 // inputs. A label that needs more than those is the standard machine.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97495 let contexts = start_contexts(run, job, &spec.id);
Fast pages, required checks on the branch, self-hosted runners, honest incidents496 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
497 let labels: Vec<String> = match expr::interpolate_value(&spec.runs_on, &scope).unwrap_or(Value::Null) {
498 Value::String(label) => vec![label],
499 Value::Array(labels) => labels.iter().map(expr::to_text).collect(),
500 Value::Object(given) => match given.get("labels") {
501 Some(Value::Array(labels)) => labels.iter().map(expr::to_text).collect(),
502 Some(label) => vec![expr::to_text(label)],
503 None => Vec::new(),
504 },
505 _ => Vec::new(),
506 };
507 let instance = g1t_contracts::actions::instance_for(&labels);
508 StartDetails {
509 environment,
510 instance: (instance != g1t_contracts::actions::STANDARD_INSTANCE).then(|| instance.label.to_owned()),
511 }
512}
513
The balance shows what was spent this month and in total514fn now() -> String {
515 rfc3339(now_ms())
516}
517
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97518/// How a finished run went for one environment, from the conclusions of
519/// the jobs that deploy to it: failed if any failed, an error if any was
520/// cancelled, else a success; nothing when none ran (all skipped).
521pub(crate) fn deployment_outcome(conclusions: &[Option<String>]) -> Option<&'static str> {
522 let ran: Vec<&str> = conclusions.iter().flatten().map(String::as_str).filter(|c| *c != "skipped").collect();
523 if ran.is_empty() {
524 return None;
525 }
526 if ran.iter().any(|c| *c == "failure" || *c == "timed_out") {
527 return Some("failure");
528 }
529 if ran.contains(&"cancelled") {
530 return Some("error");
531 }
532 Some("success")
533}
534
The balance shows what was spent this month and in total535impl Actions {
536 pub async fn run_row(&self, id: &str) -> Result<Option<RunRow>> {
537 self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<RunRow>(None).await
538 }
539
540 pub async fn job_rows(&self, run_id: &str) -> Result<Vec<JobRow>> {
541 self.db
542 .prepare("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid")
543 .bind(&[run_id.into()])?
544 .all()
545 .await?
546 .results::<JobRow>()
547 }
548
549 pub async fn run_summary(&self, id: &str) -> Result<Outcome<WorkflowRun>> {
550 Ok(match self.run_row(id).await? {
551 Some(row) => Outcome::Ok(row.summary()),
552 None => fail(FailureCode::NotFound, "No such run."),
553 })
554 }
555
556 /// The contexts every expression outside a job's steps may use.
557 fn base_contexts(run: &RunRow, vars: &Map<String, Value>, job: &str) -> Map<String, Value> {
558 let mut contexts = Map::new();
559 contexts.insert("github".into(), run.info().context(job, "", run.action.as_deref()));
560 contexts.insert("inputs".into(), Value::Object(run.inputs()));
561 contexts.insert("vars".into(), Value::Object(vars.clone()));
562 contexts.insert("needs".into(), json!({}));
563 contexts.insert("runner".into(), runner_context());
564 contexts
565 }
566
567 /// Makes a run and its jobs, and starts what can start. `None` when the
568 /// event already started this workflow.
569 pub async fn create_run(&self, new: NewRun) -> Result<Option<String>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look570 // Nothing starts on an archived repository. A deleted one is never
571 // found to start anything on.
572 if new.repo.archived() {
573 return Ok(None);
574 }
The balance shows what was spent this month and in total575 let workflow_row = self.workflow_row(&new.repo, &new.path, &new.workflow.display_name(&new.path), &new.source).await?;
576 let numbered = self
577 .db
578 .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n")
579 .bind(&[workflow_row.id.as_str().into()])?
580 .first::<Count>(None)
581 .await?
582 .map_or(1, |count| count.n);
583 let id = new_id("run", now_ms());
584 let mut info = new.info.clone();
585 info.workflow = new.workflow.display_name(&new.path);
586 info.workflow_path = new.path.clone();
587 info.run_id = id.clone();
588 info.run_number = u64::from(numbered);
589 let vars = self
590 .variables_for(&new.repo.id, &format!("{}/{}", new.repo.namespace, new.repo.name), None, new.trusted)
591 .await?;
592
593 // run-name and the concurrency group read github, inputs and vars.
594 let mut contexts = Map::new();
595 contexts.insert("github".into(), info.context("", "", new.action.as_deref()));
596 contexts.insert("inputs".into(), Value::Object(new.inputs.clone()));
597 contexts.insert("vars".into(), Value::Object(vars));
598 let scope = Scope {
599 contexts: &contexts,
600 status: Status::Success,
601 hash_files: None,
602 };
603 let title = new
604 .workflow
605 .run_name
606 .as_deref()
607 .and_then(|run_name| expr::interpolate(run_name, &scope).ok())
608 .filter(|title| !title.trim().is_empty())
609 .unwrap_or(new.title.clone());
610 let group = new.workflow.concurrency.as_ref().and_then(|c| expr::interpolate(&c.group, &scope).ok());
611 let cancel_in_progress = new
612 .workflow
613 .concurrency
614 .as_ref()
615 .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok())
616 .is_some_and(|value| expr::truthy(&value));
617
Merge branch 'worktree-agent-a3abfcce648e87dca'618 let approval = new.approval.as_ref().map(|reason| RunApproval { state: "required".into(), reason: reason.clone(), approved_by: None });
The balance shows what was spent this month and in total619 let inserted = self
620 .db
621 .prepare(
622 "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, action, git_ref, sha,
Merge branch 'worktree-agent-a3abfcce648e87dca'623 pull, status, actor, actor_id, source, info, inputs, trusted, concurrency_group, event_key, created_at,
624 approval, cancel_in_progress)
625 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
The balance shows what was spent this month and in total626 )
627 .bind(&[
628 id.as_str().into(),
629 workflow_row.id.as_str().into(),
630 new.repo.id.as_str().into(),
631 format!("{}/{}", new.repo.namespace, new.repo.name).into(),
632 new.path.as_str().into(),
633 info.workflow.as_str().into(),
634 title.as_str().into(),
635 numbered.into(),
636 info.event_name.as_str().into(),
637 optional(new.action.as_deref()),
638 info.git_ref.as_str().into(),
639 info.sha.as_str().into(),
640 new.pull.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
Merge branch 'worktree-agent-a3abfcce648e87dca'641 if approval.is_some() { "action_required" } else { "queued" }.into(),
The balance shows what was spent this month and in total642 optional(new.actor.as_deref()),
643 optional(new.actor_id.as_deref()),
644 new.source.as_str().into(),
645 serde_json::to_string(&info)?.into(),
646 serde_json::to_string(&new.inputs)?.into(),
647 u32::from(new.trusted).into(),
648 optional(group.as_deref()),
649 new.event_key.as_str().into(),
650 now().into(),
Merge branch 'worktree-agent-a3abfcce648e87dca'651 approval.as_ref().map(serde_json::to_string).transpose()?.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
652 u32::from(cancel_in_progress).into(),
The balance shows what was spent this month and in total653 ])?
654 .first::<Value>(None)
655 .await?;
656 if inserted.is_none() {
657 return Ok(None);
658 }
659 if let Some(run) = self.run_row(&id).await? {
660 self.report_pending(&run).await?;
661 }
662
663 // Every job, waiting; each is expanded when the jobs it needs are done.
664 let mut statements = Vec::new();
665 for job in &new.workflow.jobs {
666 statements.push(
667 self.db
668 .prepare(
669 "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting')",
670 )
671 .bind(&[
672 new_id("job", now_ms()).into(),
673 id.as_str().into(),
674 new.repo.id.as_str().into(),
675 new.repo.namespace.as_str().into(),
676 job.id.as_str().into(),
677 job.name.clone().filter(|n| !expr::has_expression(n)).unwrap_or(job.id.clone()).into(),
678 serde_json::to_string(&job.needs)?.into(),
679 ])?,
680 );
681 }
682 self.db.batch(statements).await?;
683
Merge branch 'worktree-agent-a3abfcce648e87dca'684 // A pull request's run from outside waits to be approved; it joins
685 // its concurrency group once it is (protection.rs, approve_run).
686 if approval.is_some() {
687 return Ok(Some(id));
688 }
689 if let Some(run) = self.run_row(&id).await? {
690 self.enter_group(&run).await?;
691 }
692 Ok(Some(id))
693 }
694
695 /// A run about to start: one run at a time per concurrency group, as on
696 /// GitHub. A newer run replaces one that waits in the group, and with
697 /// `cancel-in-progress` the one that runs; otherwise it waits as
698 /// `pending` for the one that runs. Then it moves along.
699 pub(crate) async fn enter_group(&self, run: &RunRow) -> Result<()> {
700 if let Some(group) = &run.concurrency_group {
701 let cancel_in_progress = run.cancel_in_progress != 0;
The balance shows what was spent this month and in total702 let others = self
703 .db
Merge branch 'worktree-agent-a3abfcce648e87dca'704 .prepare(
705 "SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND id != ? AND status NOT IN ('completed', 'action_required') ORDER BY id",
706 )
707 .bind(&[run.repo_id.as_str().into(), group.as_str().into(), run.id.as_str().into()])?
The balance shows what was spent this month and in total708 .all()
709 .await?
710 .results::<RunRow>()?;
711 for other in &others {
712 if cancel_in_progress || other.status == "pending" {
713 // A newer run replaces a waiting one, as on GitHub.
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)714 self.cancel_run(other, "A newer run in the same concurrency group replaced it.", false).await?;
The balance shows what was spent this month and in total715 }
716 }
717 if !cancel_in_progress && others.iter().any(|other| other.status != "pending") {
718 self.db
719 .prepare("UPDATE runs SET status = 'pending' WHERE id = ?")
Merge branch 'worktree-agent-a3abfcce648e87dca'720 .bind(&[run.id.as_str().into()])?
The balance shows what was spent this month and in total721 .run()
722 .await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'723 return Ok(());
The balance shows what was spent this month and in total724 }
725 }
Merge branch 'worktree-agent-a3abfcce648e87dca'726 self.advance(&run.id).await
The balance shows what was spent this month and in total727 }
728
729 /// A run that could not start, such as for a workflow file that does not read.
730 #[allow(clippy::too_many_arguments)]
731 pub async fn record_failed_run(
732 &self,
733 row: &WorkflowRow,
734 git_ref: &str,
735 sha: &str,
736 event_key: &str,
737 actor_id: Option<&str>,
738 actor: &str,
739 problem: &str,
740 ) -> Result<()> {
741 let numbered = self
742 .db
743 .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n")
744 .bind(&[row.id.as_str().into()])?
745 .first::<Count>(None)
746 .await?
747 .map_or(1, |count| count.n);
748 let at = now();
749 self.db
750 .prepare(
751 "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, git_ref, sha, status,
752 conclusion, error, actor, actor_id, source, info, event_key, created_at, finished_at)
753 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'push', ?, ?, 'completed', 'failure', ?, ?, ?, ?, '{}', ?, ?, ?)",
754 )
755 .bind(&[
756 new_id("run", now_ms()).into(),
757 row.id.as_str().into(),
758 row.repo_id.as_str().into(),
759 row.repo.as_str().into(),
760 row.path.as_str().into(),
761 row.path.as_str().into(),
762 "Invalid workflow file".into(),
763 numbered.into(),
764 git_ref.into(),
765 sha.into(),
766 problem.into(),
767 actor.into(),
768 optional(actor_id),
769 row.source.as_str().into(),
770 event_key.into(),
771 at.as_str().into(),
772 at.as_str().into(),
773 ])?
774 .run()
775 .await?;
776 Ok(())
777 }
778
779 /// Moves a run along: jobs whose needs are done are decided on, and
780 /// jobs that can start are started.
781 pub async fn advance(&self, run_id: &str) -> Result<()> {
782 // Each pass may finish jobs (skipped ones), which may free others.
783 for _ in 0..20 {
784 let Some(run) = self.run_row(run_id).await? else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'785 if matches!(run.status.as_str(), "completed" | "pending" | "action_required") {
The balance shows what was spent this month and in total786 return Ok(());
787 }
788 let workflow = match workflow::parse(&run.source) {
789 Ok(workflow) => workflow,
790 Err(problem) => {
791 self.finish_run(&run, Some(&problem)).await?;
792 return Ok(());
793 }
794 };
795 let jobs = self.job_rows(run_id).await?;
796 let mut changed = false;
797 // What to decide on: the workflow's jobs, and the jobs of the
798 // workflows they call, each with its needs as (name, key).
799 let mut units: Vec<Unit> = workflow
800 .jobs
801 .iter()
802 .map(|job| (job.id.clone(), job.clone(), job.needs.iter().map(|n| (n.clone(), n.clone())).collect()))
803 .collect();
804 let mut seen = std::collections::HashSet::new();
805 for row in &jobs {
806 if !seen.insert(row.key.clone()) {
807 continue;
808 }
809 if let Some((_, job, call)) = row.callee() {
810 let parent = call["parent"].as_str().unwrap_or_default().to_owned();
811 let needs = job.needs.iter().map(|n| (n.clone(), format!("{parent}/{n}"))).collect();
812 units.push((row.key.clone(), job, needs));
813 }
814 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents815 // Each key's needs, as keys, to look back through for failure().
816 let needs_of: std::collections::HashMap<&str, Vec<&str>> =
817 units.iter().map(|(key, _, needs)| (key.as_str(), needs.iter().map(|(_, need)| need.as_str()).collect())).collect();
818 let key_failed = |key: &str| key_result(&jobs.iter().filter(|row| row.key == key).collect::<Vec<_>>()) == "failure";
The balance shows what was spent this month and in total819 for (key, job, needs) in &units {
820 let rows: Vec<&JobRow> = jobs.iter().filter(|row| &row.key == key).collect();
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.821 if rows.is_empty() || !rows.iter().all(|row| row.status == "waiting" && !row.holds_call()) {
The balance shows what was spent this month and in total822 continue;
823 }
824 let needed: Vec<(&String, Vec<&JobRow>)> =
825 needs.iter().map(|(name, need)| (name, jobs.iter().filter(|row| &row.key == need).collect())).collect();
826 if !needed.iter().all(|(_, rows)| rows.iter().all(|row| row.status == "completed")) {
827 continue;
828 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents829 let failed_before = ancestor_failed(&needs_of, key, key_failed);
830 self.decide(&run, job, rows[0], &needed, failed_before).await?;
The balance shows what was spent this month and in total831 changed = true;
832 }
833 // A job that called a workflow finishes with that workflow's jobs.
834 for row in jobs.iter().filter(|row| row.status == "calling") {
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.835 let prefix = row.call_prefix().unwrap_or(row.key.clone());
The balance shows what was spent this month and in total836 let children: Vec<&JobRow> = jobs
837 .iter()
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.838 .filter(|child| child.call().is_some_and(|call| call["role"] == "callee" && call["parent"].as_str() == Some(prefix.as_str())))
The balance shows what was spent this month and in total839 .collect();
840 if !children.is_empty() && children.iter().all(|child| child.status == "completed") {
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.841 self.finish_call(&run, row, &children).await?;
The balance shows what was spent this month and in total842 changed = true;
843 }
844 }
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.845 // Calls that wait start while their matrix's `max-parallel` and
846 // their concurrency group have room.
847 let mut calling: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
848 for row in jobs.iter().filter(|row| row.status == "calling") {
849 *calling.entry(row.key.as_str()).or_default() += 1;
850 }
851 for row in jobs.iter().filter(|row| row.holds_call()) {
852 let running = calling.get(row.key.as_str()).copied().unwrap_or(0);
853 if row.max_parallel.is_some_and(|max| running >= max) || self.group_busy(row).await? {
854 continue;
855 }
856 if self.start_call(row).await? {
857 calling.insert(row.key.as_str(), running + 1);
858 changed = true;
859 }
860 }
The balance shows what was spent this month and in total861 if !changed {
862 break;
863 }
864 }
865 self.start_queued().await?;
866 self.finish_if_done(run_id).await
867 }
868
869 /// Decides on one job whose needs are done: skip it, fail it, or expand
Fast pages, required checks on the branch, self-hosted runners, honest incidents870 /// it into its matrix and queue it. `failed_before`: whether any job
871 /// before it failed, however far back (`ancestor_failed`).
872 async fn decide(&self, run: &RunRow, job: &workflow::Job, row: &JobRow, needed: &[(&String, Vec<&JobRow>)], failed_before: bool) -> Result<()> {
The balance shows what was spent this month and in total873 let vars = self.variables_for(&run.repo_id, &run.repo, None, run.trusted != 0).await?;
874 let mut contexts = Self::base_contexts(run, &vars, &job.id);
875 // A called workflow's jobs read the inputs they were called with.
876 let call = row.call();
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.877 // Shown under the job whose call made it, by that job's name.
878 let caller = call.as_ref().filter(|c| c["role"] == "callee").and_then(|c| {
879 c["title"].as_str().map(str::to_owned).or_else(|| c["parent"].as_str().map(|parent| parent.replace('/', " / ")))
880 });
The balance shows what was spent this month and in total881 if let Some(call) = call.as_ref().filter(|c| c["role"] == "callee") {
882 contexts.insert("inputs".into(), call["inputs"].clone());
883 }
884 let mut needs = Map::new();
Fast pages, required checks on the branch, self-hosted runners, honest incidents885 let mut results = Vec::new();
The balance shows what was spent this month and in total886 for (key, rows) in needed {
887 let result = key_result(rows);
888 let mut outputs = Map::new();
889 for row in rows {
890 if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) {
891 outputs.extend(more);
892 }
893 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents894 results.push(result);
The balance shows what was spent this month and in total895 needs.insert((*key).clone(), json!({ "result": result, "outputs": outputs }));
896 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents897 // As on GitHub: a need that was skipped makes success() false but
898 // not failure(); failure() is a failure anywhere before the job.
899 let status = expr::job_status(results, failed_before, run.conclusion.as_deref() == Some("cancelled"));
The balance shows what was spent this month and in total900 contexts.insert("needs".into(), Value::Object(needs));
901 let scope = Scope {
902 contexts: &contexts,
903 status,
904 hash_files: None,
905 };
906 let condition = job.condition.as_deref().unwrap_or_default();
907 match expr::condition(condition, &scope) {
908 Ok(true) => {}
909 Ok(false) => return self.skip_job(row, None).await,
910 Err(problem) => return self.fail_job(row, &format!("Its `if` does not read: {problem}")).await,
911 }
912 // Its matrix, which may come from a needed job's outputs.
913 let combinations = match &job.matrix {
914 None => vec![Map::new()],
915 Some(matrix) => {
916 let value = match expr::interpolate_value(matrix, &scope) {
917 Ok(value) => value,
918 Err(problem) => return self.fail_job(row, &format!("Its matrix does not read: {problem}")).await,
919 };
920 match matrix::expand(&value) {
921 Ok(combinations) if !combinations.is_empty() => combinations,
922 Ok(_) => return self.fail_job(row, "Its matrix makes no jobs.").await,
923 Err(problem) => return self.fail_job(row, &problem).await,
924 }
925 }
926 };
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.927 // A job that calls a reusable workflow calls it once for each.
928 if let Some(uses) = &job.uses {
929 return self.call_workflow(run, job, row, uses, &contexts, &combinations, caller.as_deref()).await;
930 }
The balance shows what was spent this month and in total931 let total = combinations.len();
932 let raw = job.raw.as_object().cloned().unwrap_or_default();
Fast pages, required checks on the branch, self-hosted runners, honest incidents933 // Whether pull requests from forks may use self-hosted runners here,
934 // asked once, and only for a run that is not trusted.
935 let mut forks_allowed: Option<bool> = None;
Merge branch 'worktree-agent-a3abfcce648e87dca'936 // Each concurrency group its jobs join, and whether it cancels.
937 let mut groups: Vec<(String, bool)> = Vec::new();
The balance shows what was spent this month and in total938 let mut statements = Vec::new();
939 for (index, combination) in combinations.iter().enumerate() {
940 let mut contexts = contexts.clone();
941 contexts.insert("matrix".into(), Value::Object(combination.clone()));
942 contexts.insert(
943 "strategy".into(),
944 json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }),
945 );
946 let scope = Scope {
947 contexts: &contexts,
948 status: Status::Success,
949 hash_files: None,
950 };
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.951 let name = shown_name(job, caller.as_deref(), combination, &scope);
The balance shows what was spent this month and in total952 let runs_on = expr::interpolate_value(&job.runs_on, &scope).unwrap_or(Value::Null);
953 let labels = match &runs_on {
954 Value::String(label) => vec![label.clone()],
955 Value::Array(labels) => labels.iter().map(expr::to_text).collect(),
956 Value::Object(spec) => spec.get("labels").map(|l| match l {
957 Value::Array(labels) => labels.iter().map(expr::to_text).collect(),
958 other => vec![expr::to_text(other)],
959 }).unwrap_or_default(),
960 _ => Vec::new(),
961 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents962 // `runs-on: self-hosted` (or a group): the workspace's own
963 // machines, which may run any OS. Otherwise g1t's Linux sandboxes.
964 let group = match &runs_on {
965 Value::Object(spec) => spec.get("group").map(expr::to_text),
966 _ => None,
967 };
968 let wanted = Wanted::of(&labels, group.as_deref());
969 let mut reason = if wanted.self_hosted {
970 None
971 } else {
972 labels
973 .iter()
974 .find(|label| {
975 let lower = label.to_ascii_lowercase();
976 lower.contains("windows") || lower.contains("macos")
977 })
978 .map(|label| {
979 let os = if label.to_ascii_lowercase().contains("windows") { "windows" } else { "macos" };
980 format!("`runs-on: {label}`: g1t's own runners are Linux. A self-hosted runner can run it: `runs-on: [self-hosted, {os}]`.")
981 })
982 };
983 // A pull request from a fork runs code anyone could write: never
984 // on the workspace's machines unless it said they may.
985 if wanted.self_hosted && run.trusted == 0 {
986 let allowed = match forks_allowed {
987 Some(allowed) => allowed,
988 None => {
989 let allowed = self.effective_runner_settings(&row.namespace, Some(&run.repo_id)).await?.fork_pull_requests;
990 forks_allowed = Some(allowed);
991 allowed
992 }
993 };
994 if !allowed {
995 reason = Some(
996 "Pull requests from forks do not run on self-hosted runners here. An admin can allow it under Settings, Runners.".to_owned(),
997 );
998 }
999 }
1000 let max_minutes = if wanted.self_hosted { SELF_HOSTED_MAX_TIMEOUT_MINUTES } else { MAX_TIMEOUT_MINUTES };
The balance shows what was spent this month and in total1001 let timeout = raw
1002 .get("timeout-minutes")
1003 .and_then(|value| expr::interpolate_value(value, &scope).ok())
1004 .and_then(|value| value.as_f64().or_else(|| expr::to_text(&value).parse().ok()))
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.1005 .map_or(g1t_actions::workflow::DEFAULT_TIMEOUT_MINUTES.min(max_minutes), |minutes| (minutes.ceil() as u32).clamp(1, max_minutes));
The balance shows what was spent this month and in total1006 let continue_on_error = raw
1007 .get("continue-on-error")
1008 .and_then(|value| expr::interpolate_value(value, &scope).ok())
1009 .is_some_and(|value| expr::truthy(&value));
Merge branch 'worktree-agent-a3abfcce648e87dca'1010 let (mut status, mut conclusion, mut finished) = match &reason {
The balance shows what was spent this month and in total1011 Some(_) => ("completed", Some("failure"), Some(now())),
1012 None => ("queued", None, None),
1013 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents1014 // A self-hosted job waits, saying for what, until a runner takes it.
1015 let (labels_json, queued_at) = if wanted.self_hosted && reason.is_none() {
1016 reason = Some(waiting_reason(&wanted));
1017 (Some(serde_json::to_string(&wanted.stored())?), Some(now()))
1018 } else {
1019 (None, None)
1020 };
Merge branch 'worktree-agent-a3abfcce648e87dca'1021 // The environment it names, an expression read now, and what
1022 // that environment's protection rules say: it starts, waits as
1023 // `pending` (protection.rs), or may not deploy there.
1024 let environment = environment_of(&job.raw, &contexts).map(|env| env.name);
1025 if status == "queued"
1026 && let Some(name) = &environment
1027 {
1028 match self.gate(run, name).await? {
1029 Gate::Open => {}
1030 Gate::Held(why) => {
1031 status = "pending";
1032 reason = Some(why);
1033 }
1034 Gate::Refused(why) => {
1035 (status, conclusion, finished) = ("completed", Some("failure"), Some(now()));
1036 reason = Some(why);
1037 }
1038 }
1039 }
1040 // Its own concurrency group.
1041 let job_group = job
1042 .concurrency
1043 .as_ref()
1044 .and_then(|c| expr::interpolate(&c.group, &scope).ok())
1045 .map(|group| group.trim().to_owned())
1046 .filter(|group| !group.is_empty());
1047 let job_cancel = job
1048 .concurrency
1049 .as_ref()
1050 .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok())
1051 .is_some_and(|value| expr::truthy(&value));
1052 if status != "completed"
1053 && let Some(group) = &job_group
1054 && !groups.iter().any(|(known, _)| known == group)
1055 {
1056 groups.push((group.clone(), job_cancel));
1057 }
The balance shows what was spent this month and in total1058 let values: Vec<worker::wasm_bindgen::JsValue> = vec![
1059 name.into(),
1060 serde_json::to_string(combination)?.into(),
1061 status.into(),
1062 optional(conclusion),
1063 optional(reason.as_deref()),
1064 timeout.into(),
1065 u32::from(continue_on_error).into(),
1066 job.max_parallel.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1067 optional(finished.as_deref()),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1068 optional(labels_json.as_deref()),
1069 optional(queued_at.as_deref()),
Merge branch 'worktree-agent-a3abfcce648e87dca'1070 optional(environment.as_deref()),
1071 optional(job_group.as_deref()),
1072 u32::from(job_cancel).into(),
The balance shows what was spent this month and in total1073 ];
1074 if index == 0 {
1075 let mut bound = values;
1076 bound.push(row.id.as_str().into());
1077 statements.push(
1078 self.db
1079 .prepare(
1080 "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, timeout_minutes = ?,
Merge branch 'worktree-agent-a3abfcce648e87dca'1081 continue_on_error = ?, max_parallel = ?, finished_at = ?, labels = ?, queued_at = ?, environment = ?,
1082 concurrency_group = ?, cancel_in_progress = ? WHERE id = ?",
The balance shows what was spent this month and in total1083 )
1084 .bind(&bound)?,
1085 );
1086 } else {
1087 let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![
1088 new_id("job", now_ms()).into(),
1089 row.run_id.as_str().into(),
1090 row.repo_id.as_str().into(),
1091 row.namespace.as_str().into(),
1092 row.key.as_str().into(),
1093 (index as u32).into(),
1094 row.needs.as_str().into(),
1095 optional(row.call.as_deref()),
1096 ];
1097 bound.extend(values);
1098 statements.push(
1099 self.db
1100 .prepare(
1101 "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, call, name, matrix, status, conclusion, reason,
Merge branch 'worktree-agent-a3abfcce648e87dca'1102 timeout_minutes, continue_on_error, max_parallel, finished_at, labels, queued_at, environment,
1103 concurrency_group, cancel_in_progress)
1104 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
The balance shows what was spent this month and in total1105 )
1106 .bind(&bound)?,
1107 );
1108 }
1109 }
1110 self.db.batch(statements).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1111 self.replace_in_groups(run, &row.key, &groups).await
1112 }
1113
1114 /// A job queued in its own concurrency group: with `cancel-in-progress`
1115 /// it cancels the group's other jobs; otherwise it replaces any that
1116 /// are queued and not started, and waits for the one running
1117 /// (`start_queued` starts one of a group at a time). As on GitHub.
1118 async fn replace_in_groups(&self, run: &RunRow, key: &str, groups: &[(String, bool)]) -> Result<()> {
1119 if groups.is_empty() {
1120 return Ok(());
1121 }
1122 #[derive(Deserialize)]
1123 struct Id {
1124 id: String,
1125 }
1126 let mine: Vec<String> = self
1127 .db
1128 .prepare("SELECT id FROM jobs WHERE run_id = ? AND key = ?")
1129 .bind(&[run.id.as_str().into(), key.into()])?
1130 .all()
1131 .await?
1132 .results::<Id>()?
1133 .into_iter()
1134 .map(|row| row.id)
1135 .collect();
1136 let mut moved: Vec<String> = Vec::new();
1137 for (group, cancel) in groups {
1138 let others = self
1139 .db
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.1140 // A call that waits in the group, or calls, is in it too.
1141 .prepare("SELECT * FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('waiting', 'queued', 'pending', 'in_progress', 'calling')")
Merge branch 'worktree-agent-a3abfcce648e87dca'1142 .bind(&[run.repo_id.as_str().into(), group.as_str().into()])?
1143 .all()
1144 .await?
1145 .results::<JobRow>()?;
1146 for other in others.iter().filter(|other| !mine.contains(&other.id)) {
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.1147 if *cancel || matches!(other.status.as_str(), "queued" | "waiting") {
Merge branch 'worktree-agent-a3abfcce648e87dca'1148 self.stop_job(other, "A newer job in the same concurrency group replaced it.").await?;
1149 if !moved.contains(&other.run_id) {
1150 moved.push(other.run_id.clone());
1151 }
1152 }
1153 }
1154 }
1155 for other_run in moved.iter().filter(|id| **id != run.id) {
1156 Box::pin(self.advance(other_run)).await?;
1157 }
The balance shows what was spent this month and in total1158 Ok(())
1159 }
1160
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1161 /// A job that calls a reusable workflow, in the repository or another
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.1162 /// (reach.rs): once for each combination of its matrix, as on GitHub,
1163 /// each with the inputs and secrets it passes read with that
1164 /// combination's `matrix`. Each call waits (`holds_call`) until its
1165 /// matrix's `max-parallel` and its concurrency group have room, when
1166 /// `start_call` puts the workflow's jobs in the run under it.
1167 #[allow(clippy::too_many_arguments)]
1168 async fn call_workflow(
1169 &self,
1170 run: &RunRow,
1171 job: &workflow::Job,
1172 row: &JobRow,
1173 uses: &str,
1174 contexts: &Map<String, Value>,
1175 combinations: &[Map<String, Value>],
1176 caller: Option<&str>,
1177 ) -> Result<()> {
The balance shows what was spent this month and in total1178 let depth = row.call().and_then(|c| c["depth"].as_u64()).unwrap_or(0) + 1;
1179 if depth > MAX_CALL_DEPTH {
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.1180 return self.fail_job(row, &call_allowed(depth, &[], uses).unwrap_err()).await;
The balance shows what was spent this month and in total1181 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1182 let (file, source, origin) = match self.called_workflow(run, row, uses).await? {
1183 Ok(found) => found,
1184 Err(why) => return self.fail_job(row, &why).await,
The balance shows what was spent this month and in total1185 };
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.1186 let jobs = self.job_rows(&run.id).await?;
1187 // Each call so far, by the workflow it called.
1188 let called_paths: Vec<String> = jobs
1189 .iter()
1190 .filter(|other| other.call_prefix().is_some())
1191 .filter_map(JobRow::call)
1192 .filter_map(|call| call["path"].as_str().map(str::to_owned))
1193 .collect();
1194 let called_already: Vec<&str> = called_paths.iter().map(String::as_str).collect();
1195 if let Err(why) = call_allowed(depth, &called_already, &file) {
1196 return self.fail_job(row, &why).await;
1197 }
The balance shows what was spent this month and in total1198 let called = match workflow::parse(&source) {
1199 Ok(called) => called,
1200 Err(problem) => return self.fail_job(row, &format!("`{file}` does not read: {problem}")).await,
1201 };
1202 let Some(trigger) = called.trigger("workflow_call") else {
1203 return self.fail_job(row, &format!("`{file}` cannot be called: it has no `on: workflow_call`.")).await;
1204 };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1205 // Secrets: none but the job's token unless `secrets:` passes them,
1206 // by name or with `inherit`; read when each job starts (job_spec).
1207 let outer = row.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned());
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.1208 let total = combinations.len();
1209 let mut groups: Vec<(String, bool)> = Vec::new();
1210 let mut statements = Vec::new();
1211 for (index, combination) in combinations.iter().enumerate() {
1212 let mut contexts = contexts.clone();
1213 contexts.insert("matrix".into(), Value::Object(combination.clone()));
1214 contexts.insert(
1215 "strategy".into(),
1216 json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }),
1217 );
1218 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
1219 let name = shown_name(job, caller, combination, &scope);
1220 // Inputs: what `with:` passes, as the called workflow types them.
1221 let given = match job.raw.get("with") {
1222 Some(with) => match expr::interpolate_value(with, &scope) {
1223 Ok(Value::Object(given)) => Ok(given),
1224 Ok(_) => Ok(Map::new()),
1225 Err(problem) => Err(format!("Its `with` does not read: {problem}")),
1226 },
1227 None => Ok(Map::new()),
1228 };
1229 let inputs = given.and_then(|given| crate::reach::call_inputs(&file, &trigger.inputs, &given));
1230 let secrets = crate::reach::secrets_plan(&job.raw, &contexts, outer.clone());
1231 let problem = match &inputs {
1232 Err(why) => Some(why.clone()),
1233 Ok(_) => crate::reach::missing_secrets(&called.raw, &secrets)
1234 .first()
1235 .map(|name| format!("`{file}` needs the secret `{name}`: pass it under `secrets:`, or use `secrets: inherit`."))
1236 .or_else(|| {
1237 crate::reach::undeclared_secrets(&called.raw, &secrets)
1238 .first()
1239 .map(|name| format!("Invalid secret: `{name}` is not defined in `{file}`."))
1240 }),
1241 };
1242 // Its own concurrency group, which it holds while its workflow runs.
1243 let group = job
1244 .concurrency
1245 .as_ref()
1246 .and_then(|c| expr::interpolate(&c.group, &scope).ok())
1247 .map(|group| group.trim().to_owned())
1248 .filter(|group| !group.is_empty());
1249 let cancel = job
1250 .concurrency
1251 .as_ref()
1252 .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok())
1253 .is_some_and(|value| expr::truthy(&value));
1254 if problem.is_none()
1255 && let Some(group) = &group
1256 && !groups.iter().any(|(known, _)| known == group)
1257 {
1258 groups.push((group.clone(), cancel));
1259 }
1260 let call = json!({
1261 "role": "caller", "path": file, "source": source, "origin": origin, "depth": depth,
1262 "prefix": call_prefix(&row.key, job.matrix.is_some().then_some(index)), "title": name,
1263 "inputs": inputs.unwrap_or_default(), "secrets": secrets, "fail_fast": job.fail_fast,
1264 });
1265 let (status, conclusion, finished) = match &problem {
1266 Some(_) => ("completed", Some("failure"), Some(now())),
1267 None => ("waiting", None, None),
1268 };
1269 let values: Vec<worker::wasm_bindgen::JsValue> = vec![
1270 name.as_str().into(),
1271 serde_json::to_string(combination)?.into(),
1272 status.into(),
1273 optional(conclusion),
1274 optional(problem.as_deref()),
1275 job.max_parallel.map(|max| max.max(1)).map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1276 optional(finished.as_deref()),
1277 optional(group.as_deref()),
1278 u32::from(cancel).into(),
1279 serde_json::to_string(&call)?.into(),
1280 ];
1281 if index == 0 {
1282 let mut bound = values;
1283 bound.push(row.id.as_str().into());
1284 statements.push(
1285 self.db
1286 .prepare(
1287 "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, max_parallel = ?, finished_at = ?,
1288 concurrency_group = ?, cancel_in_progress = ?, call = ? WHERE id = ?",
1289 )
1290 .bind(&bound)?,
1291 );
1292 } else {
1293 let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![
1294 new_id("job", now_ms()).into(),
1295 row.run_id.as_str().into(),
1296 row.repo_id.as_str().into(),
1297 row.namespace.as_str().into(),
1298 row.key.as_str().into(),
1299 (index as u32).into(),
1300 row.needs.as_str().into(),
1301 ];
1302 bound.extend(values);
1303 statements.push(
1304 self.db
1305 .prepare(
1306 "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, name, matrix, status, conclusion, reason,
1307 max_parallel, finished_at, concurrency_group, cancel_in_progress, call)
1308 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1309 )
1310 .bind(&bound)?,
1311 );
1312 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1313 }
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.1314 self.db.batch(statements).await?;
1315 self.replace_in_groups(run, &row.key, &groups).await
1316 }
1317
1318 /// Whether another job of `row`'s concurrency group runs, or calls a
1319 /// workflow, so that `row` waits for it.
1320 async fn group_busy(&self, row: &JobRow) -> Result<bool> {
1321 let Some(group) = &row.concurrency_group else { return Ok(false) };
1322 let busy = self
1323 .db
1324 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?")
1325 .bind(&[row.repo_id.as_str().into(), group.as_str().into(), row.id.as_str().into()])?
1326 .first::<Count>(None)
1327 .await?
1328 .map_or(0, |count| count.n);
1329 Ok(busy > 0)
1330 }
1331
1332 /// A call that waited (`holds_call`) is made: the called workflow's
1333 /// jobs join the run under it, named under its name and keyed under its
1334 /// prefix. False when it no longer waits.
1335 async fn start_call(&self, row: &JobRow) -> Result<bool> {
1336 let Some(call) = row.call() else { return Ok(false) };
1337 let Some(prefix) = row.call_prefix() else { return Ok(false) };
1338 let Some(called) = call["source"].as_str().and_then(|source| workflow::parse(source).ok()) else { return Ok(false) };
1339 let file = call["path"].as_str().unwrap_or_default();
1340 let started = self
1341 .db
1342 .prepare("UPDATE jobs SET status = 'calling', reason = ?, started_at = ? WHERE id = ? AND status = 'waiting' RETURNING id")
1343 .bind(&[format!("Calls `{file}`.").into(), now().into(), row.id.as_str().into()])?
1344 .first::<Value>(None)
1345 .await?;
1346 if started.is_none() {
1347 return Ok(false);
1348 }
The balance shows what was spent this month and in total1349 let mut statements = Vec::new();
1350 for called_job in &called.jobs {
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.1351 let needs: Vec<String> = called_job.needs.iter().map(|n| format!("{prefix}/{n}")).collect();
1352 let callee = json!({
1353 "role": "callee", "parent": prefix, "title": row.name, "job": called_job.id, "path": file,
1354 "source": call["source"], "inputs": call["inputs"], "depth": call["depth"], "origin": call["origin"], "secrets": call["secrets"],
The balance shows what was spent this month and in total1355 });
1356 statements.push(
1357 self.db
1358 .prepare("INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status, call) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?)")
1359 .bind(&[
1360 new_id("job", now_ms()).into(),
1361 row.run_id.as_str().into(),
1362 row.repo_id.as_str().into(),
1363 row.namespace.as_str().into(),
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.1364 format!("{prefix}/{}", called_job.id).into(),
The balance shows what was spent this month and in total1365 format!("{} / {}", row.name, called_job.name.clone().unwrap_or(called_job.id.clone())).into(),
1366 serde_json::to_string(&needs)?.into(),
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.1367 serde_json::to_string(&callee)?.into(),
The balance shows what was spent this month and in total1368 ])?,
1369 );
1370 }
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.1371 if !statements.is_empty() {
1372 self.db.batch(statements).await?;
1373 }
1374 Ok(true)
1375 }
1376
1377 /// The runs with a call waiting in `group`, but `run_id`, moved along
1378 /// now that a job of the group has finished.
1379 async fn wake_group(&self, repo_id: &str, group: &str, run_id: &str) -> Result<()> {
1380 #[derive(Deserialize)]
1381 struct RunId {
1382 run_id: String,
1383 }
1384 let waiting = self
1385 .db
1386 .prepare("SELECT DISTINCT run_id FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status = 'waiting' AND run_id != ?")
1387 .bind(&[repo_id.into(), group.into(), run_id.into()])?
1388 .all()
1389 .await?
1390 .results::<RunId>()?;
1391 for other in waiting {
1392 Box::pin(self.advance(&other.run_id)).await?;
1393 }
The balance shows what was spent this month and in total1394 Ok(())
1395 }
1396
1397 /// A job that called a workflow, finished with its jobs: their result,
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.1398 /// and the outputs the workflow declares. A failed call of a fail-fast
1399 /// matrix stops the matrix's other calls.
1400 async fn finish_call(&self, run: &RunRow, row: &JobRow, children: &[&JobRow]) -> Result<()> {
The balance shows what was spent this month and in total1401 let call = row.call().unwrap_or_default();
1402 let called = call["source"].as_str().and_then(|s| workflow::parse(s).ok());
1403 let mut jobs_context = Map::new();
1404 let mut by_key: std::collections::BTreeMap<String, Vec<&JobRow>> = std::collections::BTreeMap::new();
1405 for child in children {
1406 by_key.entry(child.key.clone()).or_default().push(child);
1407 }
1408 for (key, rows) in &by_key {
1409 let mut outputs = Map::new();
1410 for child in rows {
1411 if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&child.outputs) {
1412 outputs.extend(more);
1413 }
1414 }
1415 let id = key.rsplit('/').next().unwrap_or(key);
1416 jobs_context.insert(id.to_owned(), json!({ "result": key_result(rows), "outputs": outputs }));
1417 }
1418 let inputs = children.first().and_then(|c| c.call()).map(|c| c["inputs"].clone()).unwrap_or(json!({}));
1419 let mut contexts = Map::new();
1420 contexts.insert("jobs".into(), Value::Object(jobs_context));
1421 contexts.insert("inputs".into(), inputs);
1422 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
1423 let mut outputs = Map::new();
1424 if let Some(Value::Object(declared)) = called.as_ref().map(|w| {
1425 let on = w.raw.get("on").or_else(|| w.raw.get("true")).cloned().unwrap_or(Value::Null);
1426 on.get("workflow_call").and_then(|c| c.get("outputs")).cloned().unwrap_or(Value::Null)
1427 }) {
1428 for (name, spec) in declared {
1429 if let Some(value) = spec.get("value") {
1430 let value = expr::interpolate_value(value, &scope).unwrap_or(Value::Null);
1431 outputs.insert(name, Value::String(expr::to_text(&value)));
1432 }
1433 }
1434 }
1435 let conclusion = key_result(children);
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.1436 let finished = self
1437 .db
1438 .prepare("UPDATE jobs SET status = 'completed', conclusion = ?, outputs = ?, finished_at = ? WHERE id = ? AND status = 'calling' RETURNING id")
The balance shows what was spent this month and in total1439 .bind(&[conclusion.into(), serde_json::to_string(&outputs)?.into(), now().into(), row.id.as_str().into()])?
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.1440 .first::<Value>(None)
The balance shows what was spent this month and in total1441 .await?;
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.1442 if finished.is_none() {
1443 return Ok(());
1444 }
1445 if conclusion == "failure" && row.matrix.as_deref().is_some_and(|m| m != "{}") && call["fail_fast"] != json!(false) {
1446 let siblings = self
1447 .db
1448 .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'")
1449 .bind(&[row.run_id.as_str().into(), row.key.as_str().into()])?
1450 .all()
1451 .await?
1452 .results::<JobRow>()?;
1453 for sibling in siblings {
1454 self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?;
1455 }
1456 }
1457 if let Some(group) = &row.concurrency_group {
1458 self.wake_group(&run.repo_id, group, &run.id).await?;
1459 }
The balance shows what was spent this month and in total1460 Ok(())
1461 }
1462
1463 /// A file's text at a commit, if it is there.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1464 pub(crate) async fn read_file(&self, path: &RepoPath, ws: &g1t_contracts::User, sha: &str, file: &str) -> Result<Option<String>> {
The balance shows what was spent this month and in total1465 let blob: Outcome<g1t_contracts::repos::BlobView> = g1t_kit::call(
1466 &self.repos,
1467 "blob",
1468 &g1t_contracts::repos::BlobArgs {
1469 path: path.clone(),
1470 viewer: Some(ws.clone()),
1471 git_ref: sha.to_owned(),
1472 file_path: file.to_owned(),
1473 },
1474 )
1475 .await?;
1476 Ok(match blob {
1477 Outcome::Ok(view) => view.text,
1478 Outcome::Fail(_) => None,
1479 })
1480 }
1481
1482 async fn skip_job(&self, row: &JobRow, reason: Option<&str>) -> Result<()> {
1483 self.db
1484 .prepare("UPDATE jobs SET status = 'completed', conclusion = 'skipped', reason = ?, finished_at = ? WHERE id = ?")
1485 .bind(&[optional(reason), now().into(), row.id.as_str().into()])?
1486 .run()
1487 .await?;
1488 Ok(())
1489 }
1490
1491 async fn fail_job(&self, row: &JobRow, reason: &str) -> Result<()> {
1492 self.db
1493 .prepare("UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ? WHERE id = ? AND status != 'completed'")
1494 .bind(&[reason.into(), now().into(), row.id.as_str().into()])?
1495 .run()
1496 .await?;
1497 Ok(())
1498 }
1499
1500 /// Starts queued jobs, oldest first, while their workspace has room.
1501 pub async fn start_queued(&self) -> Result<()> {
1502 let queued = self
1503 .db
Fast pages, required checks on the branch, self-hosted runners, honest incidents1504 // Self-hosted jobs are taken by their runners (runners.rs).
1505 .prepare("SELECT * FROM jobs WHERE status = 'queued' AND labels IS NULL ORDER BY rowid LIMIT 50")
The balance shows what was spent this month and in total1506 .all()
1507 .await?
1508 .results::<JobRow>()?;
1509 let mut running: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
1510 for job in queued {
1511 let in_workspace = match running.get(&job.namespace) {
1512 Some(n) => *n,
1513 None => {
1514 let n = self
1515 .db
Fast pages, required checks on the branch, self-hosted runners, honest incidents1516 // Only g1t's own sandboxes count against the workspace's room.
1517 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE status = 'in_progress' AND namespace = ? AND runner_id IS NULL")
The balance shows what was spent this month and in total1518 .bind(&[job.namespace.as_str().into()])?
1519 .first::<Count>(None)
1520 .await?
1521 .map_or(0, |count| count.n);
1522 running.insert(job.namespace.clone(), n);
1523 n
1524 }
1525 };
1526 if in_workspace >= RUNNING_PER_WORKSPACE {
1527 continue;
1528 }
1529 if let Some(max) = job.max_parallel {
1530 let siblings = self
1531 .db
1532 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE run_id = ? AND key = ? AND status = 'in_progress'")
1533 .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])?
1534 .first::<Count>(None)
1535 .await?
1536 .map_or(0, |count| count.n);
1537 if siblings >= max {
1538 continue;
1539 }
1540 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1541 // One job of a concurrency group runs at a time.
1542 if let Some(group) = &job.concurrency_group {
1543 let running = self
1544 .db
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.1545 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?")
Merge branch 'worktree-agent-a3abfcce648e87dca'1546 .bind(&[job.repo_id.as_str().into(), group.as_str().into(), job.id.as_str().into()])?
1547 .first::<Count>(None)
1548 .await?
1549 .map_or(0, |count| count.n);
1550 if running > 0 {
1551 continue;
1552 }
1553 }
The balance shows what was spent this month and in total1554 let token = random_hex(24);
1555 let at = now();
1556 let claimed = self
1557 .db
1558 .prepare(
1559 "UPDATE jobs SET status = 'in_progress', token_hash = ?, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id",
1560 )
1561 .bind(&[sha256_hex(&token).into(), at.as_str().into(), at.as_str().into(), job.id.as_str().into()])?
1562 .first::<Value>(None)
1563 .await?;
1564 if claimed.is_none() {
1565 continue;
1566 }
1567 running.insert(job.namespace.clone(), in_workspace + 1);
1568 self.db
1569 .prepare("UPDATE runs SET status = 'in_progress', started_at = COALESCE(started_at, ?) WHERE id = ? AND status = 'queued'")
1570 .bind(&[at.as_str().into(), job.run_id.as_str().into()])?
1571 .run()
1572 .await?;
1573 let run = self.run_row(&job.run_id).await?;
1574 let repo: RepoPath = run.as_ref().map(|run| repo_path(&run.repo)).unwrap_or(RepoPath {
1575 namespace: job.namespace.clone(),
1576 name: String::new(),
1577 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents1578 // Its environment and the machine it asked for, for the runner.
1579 let details = run.as_ref().map(|run| start_details(run, &job)).unwrap_or_default();
The balance shows what was spent this month and in total1580 let started: Outcome<Value> = g1t_kit::call(
1581 &self.runner,
1582 "start_actions_job",
1583 &StartJobArgs {
1584 job: job.id.clone(),
1585 token,
1586 repo,
1587 timeout_minutes: job.timeout_minutes,
Fast pages, required checks on the branch, self-hosted runners, honest incidents1588 workflow: run.as_ref().map(|run| run.path.clone()),
1589 environment: details.environment,
1590 trusted: run.as_ref().is_some_and(|run| run.trusted != 0),
1591 instance: details.instance,
The balance shows what was spent this month and in total1592 },
1593 )
1594 .await
1595 .unwrap_or_else(|error| fail(FailureCode::Conflict, format!("The runner could not be reached: {error}")));
1596 if let Outcome::Fail(refused) = started {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1597 // Billing would not pay for a scheduled run's job: the
1598 // schedule waits rather than making runs only to refuse them.
1599 if refused.code == FailureCode::PaymentRequired
1600 && let Some(run) = run.as_ref().filter(|run| run.event == "schedule")
1601 {
1602 self.db
1603 .prepare("UPDATE workflows SET schedule_refused_until = ? WHERE id = ?")
1604 .bind(&[rfc3339(now_ms() + crate::SCHEDULE_REFUSED_MS).into(), run.workflow_id.as_str().into()])?
1605 .run()
1606 .await?;
1607 }
The balance shows what was spent this month and in total1608 Box::pin(self.finish_job(&job.id, "failure", Some(&refused.message), None)).await?;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971609 } else {
1610 self.job_started(&job.id).await?;
The balance shows what was spent this month and in total1611 }
1612 }
1613 Ok(())
1614 }
1615
1616 /// Finishes a job and moves its run along.
1617 pub async fn finish_job(&self, job_id: &str, conclusion: &str, reason: Option<&str>, outputs: Option<&Map<String, Value>>) -> Result<()> {
1618 let finished = self
1619 .db
1620 .prepare(
1621 "UPDATE jobs SET status = 'completed', conclusion = ?, reason = COALESCE(?, reason), outputs = COALESCE(?, outputs),
1622 finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *",
1623 )
1624 .bind(&[
1625 conclusion.into(),
1626 optional(reason),
1627 outputs.map(|o| serde_json::to_string(o).unwrap_or_default()).as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1628 now().into(),
1629 job_id.into(),
1630 ])?
1631 .first::<JobRow>(None)
1632 .await?;
1633 let Some(job) = finished else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'1634 // Its G1T_TOKEN stops working with it.
1635 self.revoke_job_tokens(&job.id).await;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971636 // A job that deploys failed: so did its run's deployment, now.
1637 if conclusion == "failure" && job.continue_on_error == 0 && job.started_at.is_some()
1638 && let Some(run) = self.run_row(&job.run_id).await?
1639 && let Some(env) = deploys_to(&run, &job)
1640 {
1641 self.report_deployment(&run, &env, "failure", false).await;
1642 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents1643 // A self-hosted runner's job: the runner is free again, and its time
1644 // is recorded, at nothing.
1645 if job.runner_id.is_some() {
1646 self.released(&job).await?;
1647 }
The balance shows what was spent this month and in total1648 // Steps still marked as going are not going any more.
1649 let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
1650 let mut touched = false;
1651 for step in steps.iter_mut() {
1652 if step["status"] != "completed" {
1653 let was_running = step["status"] == "in_progress";
1654 step["status"] = json!("completed");
1655 step["conclusion"] = json!(if was_running { conclusion } else { "skipped" });
1656 touched = true;
1657 }
1658 }
1659 if touched {
1660 self.db
1661 .prepare("UPDATE jobs SET steps = ? WHERE id = ?")
1662 .bind(&[serde_json::to_string(&steps)?.into(), job.id.as_str().into()])?
1663 .run()
1664 .await?;
1665 }
1666 // fail-fast: one failed combination stops the rest of its matrix.
1667 if conclusion == "failure" && job.continue_on_error == 0 && job.matrix.as_deref().is_some_and(|m| m != "{}") {
1668 let run = self.run_row(&job.run_id).await?;
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.1669 // As its workflow, or the workflow it is a job of, says.
1670 let fail_fast = run.as_ref().and_then(|run| job_spec(run, &job)).is_none_or(|j| j.fail_fast);
The balance shows what was spent this month and in total1671 if fail_fast {
1672 let siblings = self
1673 .db
1674 .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'")
1675 .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])?
1676 .all()
1677 .await?
1678 .results::<JobRow>()?;
1679 for sibling in siblings {
1680 self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?;
1681 }
1682 }
1683 }
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.1684 // A call waiting in its concurrency group, in another run, may start.
1685 if let Some(group) = &job.concurrency_group {
1686 self.wake_group(&job.repo_id, group, &job.run_id).await?;
1687 }
The balance shows what was spent this month and in total1688 Box::pin(self.advance(&job.run_id)).await
1689 }
1690
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1691 /// Cancels a job. One that is running is told to stop (the answer to
1692 /// its next report), and runs its `if: always()` and `cancelled()`
1693 /// steps and its post steps before it ends `cancelled`; one that has
1694 /// not started is cancelled at once.
The balance shows what was spent this month and in total1695 async fn stop_job(&self, job: &JobRow, reason: &str) -> Result<()> {
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.1696 // A job calling a workflow stops with that workflow's jobs.
1697 if job.status == "calling"
1698 && let Some(prefix) = job.call_prefix()
1699 {
1700 let under = format!("{prefix}/");
1701 for child in self.job_rows(&job.run_id).await?.iter().filter(|child| child.key.starts_with(&under) && child.status != "completed") {
1702 Box::pin(self.stop_job(child, reason)).await?;
1703 }
1704 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1705 if job.status == "in_progress" && job.started_at.is_some() {
1706 self.db
1707 .prepare("UPDATE jobs SET cancel_requested_at = COALESCE(cancel_requested_at, ?), reason = ? WHERE id = ? AND status = 'in_progress'")
1708 .bind(&[now().into(), reason.into(), job.id.as_str().into()])?
1709 .run()
1710 .await?;
1711 return Ok(());
1712 }
1713 self.hard_stop(job, reason).await
1714 }
1715
1716 /// Cancels a job outright, stopping its sandbox if it has one.
1717 async fn hard_stop(&self, job: &JobRow, reason: &str) -> Result<()> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1718 // A self-hosted runner hears it was cancelled on its next poll.
1719 if job.status == "in_progress" && job.runner_id.is_none() {
The balance shows what was spent this month and in total1720 let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await;
1721 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents1722 let stopped = self
1723 .db
1724 .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *")
The balance shows what was spent this month and in total1725 .bind(&[reason.into(), now().into(), job.id.as_str().into()])?
Fast pages, required checks on the branch, self-hosted runners, honest incidents1726 .first::<JobRow>(None)
The balance shows what was spent this month and in total1727 .await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1728 if stopped.as_ref().is_some_and(|row| row.started_at.is_some()) {
1729 self.revoke_job_tokens(&job.id).await;
1730 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents1731 if let Some(stopped) = stopped.filter(|row| row.runner_id.is_some()) {
1732 self.released(&stopped).await?;
1733 }
The balance shows what was spent this month and in total1734 Ok(())
1735 }
1736
Merge branch 'worktree-agent-a3abfcce648e87dca'1737 /// Ends a job's tokens at once. A failure is logged: the token expires
1738 /// on its own soon after the job's time limit.
1739 async fn revoke_job_tokens(&self, job_id: &str) {
1740 let revoked: Result<bool> =
1741 g1t_kit::call(&self.identity, "revoke_job_tokens", &RevokeJobTokensArgs { job_id: job_id.to_owned() }).await;
1742 if let Err(error) = revoked {
1743 worker::console_error!("actions: the tokens of job {job_id} were not revoked: {error}");
1744 }
1745 }
1746
The balance shows what was spent this month and in total1747 /// Finishes the run when every job has.
1748 async fn finish_if_done(&self, run_id: &str) -> Result<()> {
1749 let Some(run) = self.run_row(run_id).await? else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'1750 if matches!(run.status.as_str(), "completed" | "pending" | "action_required") {
The balance shows what was spent this month and in total1751 return Ok(());
1752 }
1753 let jobs = self.job_rows(run_id).await?;
1754 if !jobs.iter().all(|job| job.status == "completed") {
1755 return Ok(());
1756 }
1757 self.finish_run(&run, None).await
1758 }
1759
1760 async fn finish_run(&self, run: &RunRow, error: Option<&str>) -> Result<()> {
1761 let jobs = self.job_rows(&run.id).await?;
1762 let rows: Vec<&JobRow> = jobs.iter().collect();
1763 let conclusion = if error.is_some() {
1764 "failure"
1765 } else if run.conclusion.as_deref() == Some("cancelled") {
1766 "cancelled"
1767 } else if rows.is_empty() {
1768 "skipped"
1769 } else {
1770 key_result(&rows)
1771 };
1772 let done = self
1773 .db
1774 .prepare("UPDATE runs SET status = 'completed', conclusion = ?, error = COALESCE(?, error), finished_at = ? WHERE id = ? AND status != 'completed' RETURNING id")
1775 .bind(&[conclusion.into(), optional(error), now().into(), run.id.as_str().into()])?
1776 .first::<Value>(None)
1777 .await?;
1778 if done.is_none() {
1779 return Ok(());
1780 }
1781 self.report_status(run, conclusion).await?;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971782 self.settle_deployments(run, &jobs, conclusion == "cancelled").await;
The balance shows what was spent this month and in total1783 let published: Result<()> = g1t_kit::call(
1784 &self.events,
1785 "publish",
1786 &g1t_contracts::events::Publish {
1787 events: vec![g1t_contracts::events::NewEvent {
1788 kind: "workflow.completed",
1789 source: "actions",
1790 repo_id: Some(run.repo_id.clone()),
1791 actor: run.actor_id.clone(),
1792 data: g1t_contracts::events::WorkflowEvent {
1793 run_id: run.id.clone(),
1794 repo_id: run.repo_id.clone(),
1795 workflow: run.name.clone(),
1796 path: run.path.clone(),
1797 number: run.number,
1798 event: run.event.clone(),
1799 conclusion: conclusion.to_owned(),
1800 git_ref: run.git_ref.clone(),
1801 sha: run.sha.clone(),
1802 pull: run.pull,
1803 },
1804 }],
1805 },
1806 )
1807 .await;
1808 if let Err(error) = published {
1809 worker::console_error!("actions: could not publish workflow.completed: {error}");
1810 }
1811 // The next run waiting in its concurrency group.
1812 if let Some(group) = &run.concurrency_group {
1813 let next = self
1814 .db
1815 .prepare("SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND status = 'pending' ORDER BY id LIMIT 1")
1816 .bind(&[run.repo_id.as_str().into(), group.as_str().into()])?
1817 .first::<RunRow>(None)
1818 .await?;
1819 if let Some(next) = next {
1820 self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[next.id.as_str().into()])?.run().await?;
1821 Box::pin(self.advance(&next.id)).await?;
1822 }
1823 }
1824 Ok(())
1825 }
1826
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971827 /// Tells the deployments service how a run's deployment to `env` stands:
1828 /// made when its first job naming the environment starts, failed when one
1829 /// of them fails, and settled (`last`) when the run finishes. Never fails
1830 /// the run: a deployment that could not be recorded is logged.
1831 async fn report_deployment(&self, run: &RunRow, env: &JobEnvironment, state: &str, last: bool) {
1832 let path = repo_path(&run.repo);
1833 let reported: Result<Value> = g1t_kit::call(
1834 &self.deployments,
1835 "actions_deployment",
1836 &json!({
1837 "repoId": run.repo_id,
1838 "repo": { "namespace": path.namespace, "name": path.name },
1839 "runId": run.id,
1840 "attempt": run.attempt,
1841 "runUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
1842 "environment": env.name,
1843 "url": env.url,
1844 "ref": run.git_ref,
1845 "sha": run.sha,
1846 "state": state,
1847 "final": last,
1848 "creator": run.actor,
1849 "workflow": run.name,
1850 }),
1851 )
1852 .await;
1853 if let Err(error) = reported {
1854 worker::console_error!("actions: deployment to {} not recorded for run {}: {error}", env.name, run.id);
1855 }
1856 }
1857
1858 /// A job that deploys has started: its run's deployment to the
1859 /// environment is under way.
1860 pub(crate) async fn job_started(&self, job_id: &str) -> Result<()> {
1861 let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job_id.into()])?.first::<JobRow>(None).await? else {
1862 return Ok(());
1863 };
1864 let Some(run) = self.run_row(&job.run_id).await? else { return Ok(()) };
1865 if let Some(env) = deploys_to(&run, &job) {
1866 self.report_deployment(&run, &env, "in_progress", false).await;
1867 }
1868 Ok(())
1869 }
1870
1871 /// The run is over: each environment its jobs deployed to takes the
1872 /// outcome of those jobs (`deployment_outcome`).
1873 async fn settle_deployments(&self, run: &RunRow, jobs: &[JobRow], cancelled: bool) {
1874 let mut seen: Vec<(JobEnvironment, Vec<Option<String>>)> = Vec::new();
1875 for job in jobs {
1876 let Some(env) = deploys_to(run, job) else { continue };
1877 let conclusion = if cancelled && job.conclusion.as_deref() != Some("skipped") && job.started_at.is_some() {
1878 Some("cancelled".to_owned())
1879 } else if job.started_at.is_none() {
1880 Some("skipped".to_owned())
1881 } else {
1882 job.conclusion.clone()
1883 };
1884 match seen.iter_mut().find(|(known, _)| known.name.eq_ignore_ascii_case(&env.name)) {
1885 Some((known, conclusions)) => {
1886 if known.url.is_none() {
1887 known.url = env.url.clone();
1888 }
1889 conclusions.push(conclusion);
1890 }
1891 None => seen.push((env, vec![conclusion])),
1892 }
1893 }
1894 for (env, conclusions) in seen {
1895 if let Some(state) = deployment_outcome(&conclusions) {
1896 self.report_deployment(run, &env, state, true).await;
1897 }
1898 }
1899 }
1900
The balance shows what was spent this month and in total1901 /// Tells the pull request (or commit) how the run went, as a status.
1902 async fn report_status(&self, run: &RunRow, conclusion: &str) -> Result<()> {
1903 let state = match conclusion {
1904 "success" | "skipped" => "success",
1905 "cancelled" => "error",
1906 _ => "failure",
1907 };
1908 let _: Result<Value> = g1t_kit::call(
1909 &self.work,
1910 "set_commit_status",
1911 &json!({
1912 "repoId": run.repo_id,
1913 "sha": run.sha,
1914 "context": format!("{} / {}", run.name, run.event),
1915 "state": state,
1916 "description": format!("{} {}", run.name, match conclusion {
1917 "success" => "passed",
1918 "skipped" => "was skipped",
1919 "cancelled" => "was cancelled",
1920 _ => "failed",
1921 }),
1922 "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1923 "source": "actions",
The balance shows what was spent this month and in total1924 }),
1925 )
1926 .await;
1927 Ok(())
1928 }
1929
1930 /// Tells the pull request a run has started on its head.
1931 pub async fn report_pending(&self, run: &RunRow) -> Result<()> {
1932 let _: Result<Value> = g1t_kit::call(
1933 &self.work,
1934 "set_commit_status",
1935 &json!({
1936 "repoId": run.repo_id,
1937 "sha": run.sha,
1938 "context": format!("{} / {}", run.name, run.event),
1939 "state": "pending",
Merge branch 'worktree-agent-a3abfcce648e87dca'1940 "description": if run.status == "action_required" {
1941 format!("{} is waiting for approval", run.name)
1942 } else {
1943 format!("{} is running", run.name)
1944 },
The balance shows what was spent this month and in total1945 "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1946 "source": "actions",
The balance shows what was spent this month and in total1947 }),
1948 )
1949 .await;
1950 Ok(())
1951 }
1952
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1953 /// Cancels a run: its waiting and queued jobs at once, and its running
1954 /// ones gracefully (`stop_job`), or outright when `force`.
1955 pub async fn cancel_run(&self, run: &RunRow, reason: &str, force: bool) -> Result<()> {
The balance shows what was spent this month and in total1956 self.db
1957 .prepare("UPDATE runs SET conclusion = 'cancelled' WHERE id = ? AND status != 'completed'")
1958 .bind(&[run.id.as_str().into()])?
1959 .run()
1960 .await?;
1961 for job in self.job_rows(&run.id).await?.iter().filter(|job| job.status != "completed") {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1962 if force {
1963 self.hard_stop(job, reason).await?;
1964 } else {
1965 self.stop_job(job, reason).await?;
1966 }
The balance shows what was spent this month and in total1967 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1968 if run.status == "pending" || run.status == "action_required" {
The balance shows what was spent this month and in total1969 self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[run.id.as_str().into()])?.run().await?;
1970 }
1971 self.advance(&run.id).await
1972 }
1973
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1974 /// Cancels every run of the repository that has not finished, for
1975 /// `repo.deleted` and `repo.archived`.
1976 pub async fn stop_runs(&self, repo_id: &str) -> Result<()> {
1977 let runs = self
1978 .db
1979 .prepare("SELECT * FROM runs WHERE repo_id = ? AND status != 'completed'")
1980 .bind(&[repo_id.into()])?
1981 .all()
1982 .await?
1983 .results::<RunRow>()?;
1984 for run in runs {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1985 self.cancel_run(&run, "The repository was archived or deleted.", true).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1986 }
1987 Ok(())
1988 }
1989
The balance shows what was spent this month and in total1990 pub async fn cancel(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1991 if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? {
The balance shows what was spent this month and in total1992 return Ok(Outcome::Fail(refused));
1993 }
1994 let run = check!(self.run_in(&a.repo, &a.id).await?);
1995 if run.status == "completed" {
1996 return Ok(fail(FailureCode::Conflict, "The run has already finished."));
1997 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1998 // Cancelling a run that is already cancelling stops its jobs
1999 // outright, without waiting for their cleanup steps.
2000 let force = a.force || run.conclusion.as_deref() == Some("cancelled");
2001 let reason = if force {
2002 format!("{} stopped the run without waiting for its cleanup steps.", a.actor.username)
2003 } else {
2004 format!("{} cancelled the run.", a.actor.username)
2005 };
2006 self.cancel_run(&run, &reason, force).await?;
The balance shows what was spent this month and in total2007 self.run_summary(&run.id).await
2008 }
2009
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2010 /// Runs again, as a new attempt: every job, with `failed_only` those
2011 /// that did not succeed, or with `job` that one; each with the jobs that
2012 /// need them. The attempt that ends is kept, its jobs and their logs and
2013 /// summaries with it (`job_attempts`, `run_attempts`).
The balance shows what was spent this month and in total2014 pub async fn rerun(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2015 if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? {
The balance shows what was spent this month and in total2016 return Ok(Outcome::Fail(refused));
2017 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2018 // A job alone (`POST …/jobs/{job}/rerun`) names its run.
2019 let run_id = match (&a.job, a.id.is_empty()) {
2020 (Some(job), true) => {
2021 #[derive(Deserialize)]
2022 struct Of {
2023 run_id: String,
2024 }
2025 let of = self.db.prepare("SELECT run_id FROM jobs WHERE id = ?").bind(&[job.as_str().into()])?.first::<Of>(None).await?;
2026 match of {
2027 Some(of) => of.run_id,
2028 None => return Ok(fail(FailureCode::NotFound, "No such job.")),
2029 }
2030 }
2031 _ => a.id.clone(),
2032 };
2033 let run = check!(self.run_in(&a.repo, &run_id).await?);
The balance shows what was spent this month and in total2034 if run.status != "completed" {
2035 return Ok(fail(FailureCode::Conflict, "The run is still going: cancel it first."));
2036 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2037 // Nothing starts again on an archived repository.
2038 match self.visible_repo(&a.repo, &Some(a.actor.clone())).await? {
2039 Some(repo) if repo.archived() => {
2040 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
2041 }
2042 Some(_) => {}
2043 None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")),
2044 }
The balance shows what was spent this month and in total2045 if run.error.is_some() {
2046 return Ok(fail(FailureCode::Conflict, "This run never started: fix the workflow file and push again."));
2047 }
2048 let jobs = self.job_rows(&run.id).await?;
2049 let workflow = workflow::parse(&run.source).ok();
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2050 let target = match &a.job {
2051 Some(id) => match jobs.iter().find(|job| &job.id == id) {
2052 Some(job) => Some(top_key(&job.key).to_owned()),
2053 None => return Ok(fail(FailureCode::NotFound, "That job is not in the run's latest attempt.")),
2054 },
2055 None => None,
2056 };
2057 let which = match (&target, a.failed_only) {
2058 (Some(key), _) => Rerun::Job(key),
2059 (None, true) => Rerun::Failed,
2060 (None, false) => Rerun::All,
2061 };
2062 let order = workflow.as_ref().map(|w| w.job_order()).unwrap_or_default();
2063 let again = rerun_keys(
2064 &order,
2065 |key| jobs.iter().find(|j| j.key == key).map(JobRow::needs).unwrap_or_default(),
2066 |key| jobs.iter().filter(|j| j.key == key).all(|j| j.conclusion.as_deref() == Some("success")),
2067 which,
2068 );
The balance shows what was spent this month and in total2069 if again.is_empty() {
2070 return Ok(fail(FailureCode::Conflict, "Every job succeeded: there is nothing to run again."));
2071 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2072 // The jobs that run again, a called workflow's with the job calling it.
2073 let rerun_ids: Vec<&str> = jobs.iter().filter(|job| again.iter().any(|key| key == top_key(&job.key))).map(|job| job.id.as_str()).collect();
2074 let ids = serde_json::to_string(&rerun_ids)?;
2075 let ended = run.attempt.to_string();
2076 let ended = ended.as_str();
2077 let mut statements = vec![
2078 // The attempt that ends, as it ended.
2079 self.db
2080 .prepare(
2081 "INSERT OR REPLACE INTO run_attempts (run_id, attempt, repo_id, conclusion, actor, debug, started_at, finished_at)
2082 SELECT id, attempt, repo_id, conclusion, COALESCE(triggering_actor, actor), debug, started_at, finished_at FROM runs WHERE id = ?",
2083 )
2084 .bind(&[run.id.as_str().into()])?,
2085 // Earlier attempts that showed a job's logs from where it ran
2086 // then now find them where they move to.
2087 self.db
2088 .prepare("UPDATE job_attempts SET log_id = log_id || '.' || ?1 WHERE run_id = ?2 AND log_id IN (SELECT value FROM json_each(?3))")
2089 .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?,
2090 // Each of its jobs: one that runs again keeps its logs under
2091 // `{id}.{attempt}`; one left alone is still the live job's.
2092 self.db
2093 .prepare(
2094 "INSERT OR REPLACE INTO job_attempts (id, run_id, repo_id, attempt, job_id, log_id, key, ordinal, name, needs, status, conclusion,
2095 steps, annotations, reason, environment, labels, runner_name, started_at, finished_at)
2096 SELECT id || '.' || ?1, run_id, repo_id, ?1, id,
2097 CASE WHEN id IN (SELECT value FROM json_each(?3)) THEN id || '.' || ?1 ELSE id END,
2098 key, ordinal, name, needs, status, conclusion, steps, annotations, reason, environment, labels, runner_name, started_at, finished_at
2099 FROM jobs WHERE run_id = ?2 ORDER BY rowid",
2100 )
2101 .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?,
2102 self.db
2103 .prepare("UPDATE logs SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))")
2104 .bind(&[ended.into(), ids.as_str().into()])?,
2105 self.db
2106 .prepare("UPDATE job_summaries SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))")
2107 .bind(&[ended.into(), ids.as_str().into()])?,
2108 ];
The balance shows what was spent this month and in total2109 for key in &again {
2110 statements.push(self.db.prepare("DELETE FROM jobs WHERE run_id = ? AND key = ? AND ordinal > 0").bind(&[run.id.as_str().into(), key.as_str().into()])?);
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.2111 // The jobs of a workflow it called (each matrix combination's
2112 // under `key[i]/`) are made again when it calls it again.
The balance shows what was spent this month and in total2113 statements.push(
2114 self.db
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.2115 .prepare("DELETE FROM jobs WHERE run_id = ? AND (key LIKE ? OR key LIKE ?)")
2116 .bind(&[run.id.as_str().into(), format!("{key}/%").into(), format!("{key}[%").into()])?,
The balance shows what was spent this month and in total2117 );
2118 statements.push(
2119 self.db
2120 .prepare(
2121 "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL,
Fast pages, required checks on the branch, self-hosted runners, honest incidents2122 matrix = NULL, call = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL,
Merge branch 'worktree-agent-a3abfcce648e87dca'2123 labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2124 concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?",
The balance shows what was spent this month and in total2125 )
2126 .bind(&[run.id.as_str().into(), key.as_str().into()])?,
2127 );
2128 }
2129 statements.push(
2130 self.db
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2131 .prepare(
2132 "UPDATE runs SET status = 'queued', conclusion = NULL, attempt = attempt + 1, started_at = NULL, finished_at = NULL,
2133 triggering_actor = ?, debug = ? WHERE id = ?",
2134 )
2135 .bind(&[a.actor.username.as_str().into(), u32::from(a.debug).into(), run.id.as_str().into()])?,
The balance shows what was spent this month and in total2136 );
2137 self.db.batch(statements).await?;
2138 if let Some(run) = self.run_row(&run.id).await? {
2139 self.report_pending(&run).await?;
2140 }
2141 self.advance(&run.id).await?;
2142 self.run_summary(&run.id).await
2143 }
2144
2145 pub async fn run_in(&self, repo: &RepoPath, id: &str) -> Result<Outcome<RunRow>> {
2146 let row = self
2147 .db
2148 .prepare("SELECT * FROM runs WHERE id = ? AND lower(repo) = lower(?)")
2149 .bind(&[id.into(), format!("{}/{}", repo.namespace, repo.name).into()])?
2150 .first::<RunRow>(None)
2151 .await?;
2152 Ok(row.map_or_else(|| fail(FailureCode::NotFound, "No such run."), Outcome::Ok))
2153 }
2154
2155 // --- The sandbox's side -----------------------------------------------------
2156
Fast pages, required checks on the branch, self-hosted runners, honest incidents2157 pub(crate) async fn job_for_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> {
The balance shows what was spent this month and in total2158 let job = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.job.as_str().into()])?.first::<JobRow>(None).await?;
2159 Ok(match job {
2160 Some(job) if job.status == "in_progress" && job.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(&a.token))) => {
2161 Outcome::Ok(job)
2162 }
2163 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
2164 })
2165 }
2166
2167 /// `job_auth`: which run and repository a running job's token is for,
2168 /// so the API can keep its artifacts and cache.
2169 pub async fn job_auth(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
2170 let job = check!(self.job_for_token(&a).await?);
2171 Ok(Outcome::Ok(json!({ "run": job.run_id, "repoId": job.repo_id })))
2172 }
2173
2174 /// `job_spec`: everything the sandbox needs to run the job.
2175 pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
2176 let job = check!(self.job_for_token(&a).await?);
2177 let Some(run) = self.run_row(&job.run_id).await? else {
2178 return Ok(fail(FailureCode::NotFound, "No such run."));
2179 };
2180 let Ok(caller) = workflow::parse(&run.source) else {
2181 return Ok(fail(FailureCode::Invalid, "The workflow no longer reads."));
2182 };
2183 // A called workflow's job runs as that workflow defines it.
2184 let callee = job.callee();
2185 let (workflow, spec, call_inputs) = match callee {
2186 Some((called, spec, call)) => (called, spec, Some(call["inputs"].clone())),
2187 None => match caller.jobs.iter().find(|j| j.id == job.key) {
2188 Some(spec) => (caller.clone(), spec.clone(), None),
2189 None => return Ok(fail(FailureCode::NotFound, "The job is not in the workflow.")),
2190 },
2191 };
2192 let spec = &spec;
2193 let repo = repo_path(&run.repo);
2194 let trusted = run.trusted != 0;
Merge branch 'worktree-agent-a3abfcce648e87dca'2195 // The job's `environment:`, by name, as read when its needs were done
2196 // (an expression included), once the environment's protection rules
2197 // let it start: entries with a value for it give that value instead
2198 // of their default, as GitHub's environment secrets do.
2199 let environment: Option<String> = job.environment.clone();
2200 // What its token may do: its `permissions:` (a called workflow's
2201 // jobs no more than the job that calls it), else the repository's
2202 // default; read-only for a pull request from outside.
2203 // The repository's default, its workspace's taken in, and whether
2204 // its jobs may open and approve pull requests.
2205 let (default, pull_requests) = self.token_policy(&run.repo_id).await?;
2206 let mut permissions = spec.permissions(&workflow, default);
2207 if let Some(parent) = &job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::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.2208 let top = top_key(parent);
Merge branch 'worktree-agent-a3abfcce648e87dca'2209 if let Some(caller_job) = caller.jobs.iter().find(|j| j.id == top) {
2210 permissions = permissions.capped_by(&caller_job.permissions(&caller, default));
The balance shows what was spent this month and in total2211 }
Merge branch 'worktree-agent-a3abfcce648e87dca'2212 }
2213 if !trusted {
2214 permissions = permissions.read_only();
2215 }
2216 // G1T_TOKEN, and GITHUB_TOKEN as its alias: a token of the
2217 // workspace's that reaches this repository only, with the scopes
2218 // its permissions give, until the job ends.
2219 let token = match self.workspace_actor(&repo.namespace).await? {
2220 Some(workspace) => {
2221 let created: CreatedAccessToken = g1t_kit::call(
2222 &self.identity,
2223 "create_job_token",
2224 &CreateJobTokenArgs {
2225 workspace,
2226 repo: repo.clone(),
2227 run_id: run.id.clone(),
2228 job_id: job.id.clone(),
2229 name: format!("G1T_TOKEN for {} run {}", run.repo, run.number),
2230 ttl_seconds: u64::from(job.timeout_minutes) * 60 + 600,
2231 scopes: permissions.scopes().into_iter().map(str::to_owned).collect(),
2232 pull_requests: pull_requests && trusted,
2233 },
2234 )
2235 .await?;
2236 created.token
2237 }
2238 None => String::new(),
The balance shows what was spent this month and in total2239 };
2240 // A run that is not trusted (a pull request from outside the
2241 // workspace) gets no secrets and an empty token.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2242 let passed = job.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned());
2243 let mut secrets = match (trusted, passed) {
2244 (false, _) => Map::new(),
2245 (true, None) => self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?,
2246 // A called workflow's job: what its callers passed it (reach.rs),
2247 // and its own environment's secrets over them.
2248 (true, Some(plan)) => {
2249 let base = self.secrets_for(&run.repo_id, &run.repo, None, true).await?;
2250 let vars = self.variables_for(&run.repo_id, &run.repo, None, true).await?;
2251 let github = run.info().context(&job.key, "", run.action.as_deref());
2252 let mut passed = crate::reach::resolve_secrets(&plan, &base, &github, &vars);
2253 if let Some(name) = environment.as_deref() {
2254 let own = self.secrets_for(&run.repo_id, &run.repo, Some(name), true).await?;
2255 for (key, value) in own {
2256 if base.get(&key) != Some(&value) {
2257 passed.insert(key, value);
2258 }
2259 }
2260 }
2261 passed
2262 }
The balance shows what was spent this month and in total2263 };
2264 secrets.insert("G1T_TOKEN".into(), Value::String(token.clone()));
2265 secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone()));
Merge branch 'worktree-agent-a3abfcce648e87dca'2266 // Each secret as it is, a line at a time, base64 and JSON-escaped.
2267 let mut masks: Vec<String> = g1t_actions::mask::all_variants(secrets.values().filter_map(|v| v.as_str()));
The balance shows what was spent this month and in total2268 let vars = self.variables_for(&run.repo_id, &run.repo, environment.as_deref(), trusted).await?;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R22269 // The toolkit's runtime token (runtime.rs), for as long as the job
2270 // may run; the API puts it and the toolkit's addresses in the
2271 // job's variables.
2272 let runtime_token = crate::runtime::runtime_token(
2273 &job.id,
2274 &job.run_id,
2275 job.token_hash.as_deref().unwrap_or_default(),
2276 now_ms() / 1000,
2277 u64::from(job.timeout_minutes) * 60 + 600,
2278 );
2279 masks.push(runtime_token.clone());
2280 let retention_days = self.retention_setting(&run.repo_id).await?;
The balance shows what was spent this month and in total2281
2282 let jobs = self.job_rows(&run.id).await?;
2283 let mut needs = Map::new();
2284 // In a called workflow, its jobs' keys sit under the job that called it.
2285 let parent = job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::to_owned));
2286 for need in &spec.needs {
2287 let key = match &parent {
2288 Some(parent) => format!("{parent}/{need}"),
2289 None => need.clone(),
2290 };
2291 let rows: Vec<&JobRow> = jobs.iter().filter(|row| row.key == key).collect();
2292 let mut outputs = Map::new();
2293 for row in &rows {
2294 if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) {
2295 outputs.extend(more);
2296 }
2297 }
2298 needs.insert(need.clone(), json!({ "result": key_result(&rows), "outputs": outputs }));
2299 }
2300 let siblings = jobs.iter().filter(|row| row.key == job.key).count();
2301 let matrix: Value = job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or(json!({}));
2302 let info = run.info();
2303 let mut github = info.context(&job.key, &token, run.action.as_deref());
2304 github["token"] = json!(token);
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R22305 github["retention_days"] = json!(retention_days);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2306 // On a self-hosted runner, `runner` and `RUNNER_*` describe that
2307 // machine rather than g1t's sandbox.
2308 let mut variables = info.variables(&job.key);
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R22309 variables.insert("GITHUB_RETENTION_DAYS".into(), json!(retention_days.to_string()));
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2310 let mut runner = match &job.runner_id {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2311 Some(id) => self.runner_context_for(id, &mut variables).await?,
2312 None => runner_context(),
2313 };
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2314 // A re-run with debug logging: what GitHub sets for one.
2315 if run.debug != 0 {
2316 debug_logging(&mut variables, &mut runner);
2317 }
The balance shows what was spent this month and in total2318
2319 // Where to check out: a pull request's fork, or the repository.
2320 let clone_url = match run.pull {
2321 Some(number) if run.event.starts_with("pull_request") && run.event != "pull_request_target" => {
2322 let located: Outcome<g1t_contracts::work::PullDetail> = g1t_kit::call(
2323 &self.work,
2324 "get_pull",
2325 &g1t_contracts::work::ViewArgs {
2326 repo: repo.clone(),
2327 number,
2328 viewer: self.workspace_actor(&repo.namespace).await?,
2329 after_seq: 0,
2330 },
2331 )
2332 .await?;
2333 match located {
2334 Outcome::Ok(detail) => match detail.pull.fork {
2335 Some(fork) => format!("{SITE}/{}/{}.git", fork.namespace, fork.name),
2336 None => format!("{SITE}/{}.git", run.repo),
2337 },
2338 Outcome::Fail(_) => format!("{SITE}/{}.git", run.repo),
2339 }
2340 }
2341 _ => format!("{SITE}/{}.git", run.repo),
2342 };
2343
2344 Ok(Outcome::Ok(json!({
2345 "job": job.id,
2346 "run": run.id,
2347 "key": job.key,
2348 "name": job.name,
2349 "spec": spec.raw,
2350 "workflow": {
2351 "env": workflow.env,
2352 "defaults": workflow.raw.get("defaults").cloned().unwrap_or(Value::Null),
2353 },
2354 "github": github,
Fast pages, required checks on the branch, self-hosted runners, honest incidents2355 "variables": variables,
The balance shows what was spent this month and in total2356 "event": info.event,
2357 "contexts": {
2358 "vars": vars,
2359 "secrets": secrets,
2360 "inputs": call_inputs.unwrap_or_else(|| Value::Object(run.inputs())),
2361 "matrix": matrix,
2362 "needs": needs,
2363 "strategy": {
2364 "fail-fast": spec.fail_fast,
2365 "job-index": job.ordinal,
2366 "job-total": siblings,
2367 "max-parallel": spec.max_parallel.unwrap_or(siblings as u32),
2368 },
Fast pages, required checks on the branch, self-hosted runners, honest incidents2369 "runner": runner,
The balance shows what was spent this month and in total2370 },
2371 "checkout": {
2372 "repository": run.repo,
2373 "url": clone_url,
2374 "sha": run.sha,
2375 "ref": run.git_ref,
2376 "token": token,
2377 },
2378 "timeoutMinutes": job.timeout_minutes,
2379 "masks": masks,
Merge branch 'worktree-agent-a3abfcce648e87dca'2380 // As the job's log lists them at its start.
2381 "permissions": permissions.listed().into_iter().map(|(name, access)| (name.to_owned(), json!(access.as_str()))).collect::<Map<String, Value>>(),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R22382 // Whether the job may ask for an OIDC token decides whether it
2383 // is told where to.
2384 "runtime": {
2385 "token": runtime_token,
2386 "idToken": self.oidc_allowed(&run, &job),
2387 },
The balance shows what was spent this month and in total2388 })))
2389 }
2390
2391 /// `job_report`: the sandbox telling how the job is going.
2392 pub async fn job_report(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
2393 let job = check!(self.job_for_token(&a).await?);
2394 let report = &a.report;
2395 let at = now();
2396 match report["kind"].as_str().unwrap_or_default() {
2397 "steps" => {
2398 // The list can grow as the job goes (post steps), so steps
2399 // already reported keep where they stand.
2400 let known: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
2401 let steps: Vec<Value> = report["steps"]
2402 .as_array()
2403 .map(|names| {
2404 names
2405 .iter()
2406 .enumerate()
2407 .map(|(i, name)| match known.get(i) {
2408 Some(step) if step["status"] != "queued" => step.clone(),
2409 _ => json!({ "number": i + 1, "name": expr::to_text(name), "status": "queued", "conclusion": null, "startedAt": null, "finishedAt": null }),
2410 })
2411 .collect()
2412 })
2413 .unwrap_or_default();
2414 self.db
2415 .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?")
2416 .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])?
2417 .run()
2418 .await?;
2419 }
2420 "step" => {
2421 let number = report["number"].as_u64().unwrap_or(0) as usize;
2422 let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
2423 if let Some(step) = number.checked_sub(1).and_then(|i| steps.get_mut(i)) {
2424 let status = report["status"].as_str().unwrap_or("in_progress");
2425 step["status"] = json!(status);
2426 if status == "in_progress" {
2427 step["startedAt"] = json!(at);
2428 }
2429 if status == "completed" {
2430 step["finishedAt"] = json!(at);
2431 step["conclusion"] = report["conclusion"].clone();
2432 }
2433 if let Some(name) = report["name"].as_str() {
2434 step["name"] = json!(name);
2435 }
2436 }
2437 self.db
2438 .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?")
2439 .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])?
2440 .run()
2441 .await?;
2442 }
2443 "log" => {
2444 let mut text = report["text"].as_str().unwrap_or_default().to_owned();
2445 if text.len() > MAX_CHUNK_BYTES {
2446 let mut cut = MAX_CHUNK_BYTES;
2447 while !text.is_char_boundary(cut) {
2448 cut -= 1;
2449 }
2450 text.truncate(cut);
2451 }
2452 #[derive(Deserialize)]
2453 struct Size {
2454 n: Option<f64>,
2455 seq: Option<f64>,
2456 }
2457 let size = self
2458 .db
2459 .prepare("SELECT SUM(LENGTH(text)) AS n, MAX(seq) AS seq FROM logs WHERE job_id = ?")
2460 .bind(&[job.id.as_str().into()])?
2461 .first::<Size>(None)
2462 .await?;
2463 let (used, seq) = size.map_or((0, 0.0), |s| (s.n.unwrap_or(0.0) as usize, s.seq.unwrap_or(0.0)));
2464 if used < MAX_LOG_BYTES {
2465 if used + text.len() >= MAX_LOG_BYTES {
2466 text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n");
2467 }
2468 self.db
2469 .prepare("INSERT INTO logs (job_id, seq, step, text) VALUES (?, ?, ?, ?)")
2470 .bind(&[job.id.as_str().into(), // Numbers go to D1 as f64: a u64 would be a BigInt, which it refuses.
2471 (seq + 1.0).into(), (report["step"].as_u64().unwrap_or(0) as u32).into(), text.into()])?
2472 .run()
2473 .await?;
2474 }
2475 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
2476 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2477 "summary" => {
2478 // `$GITHUB_STEP_SUMMARY`, masked by the runner: added to the
2479 // step's summary, up to 1 MiB a step and 20 steps a job.
2480 let step = report["step"].as_u64().unwrap_or(0) as u32;
2481 let markdown = report["markdown"].as_str().unwrap_or_default();
2482 #[derive(Deserialize)]
2483 struct Held {
2484 steps: u32,
2485 mine: Option<f64>,
2486 }
2487 let held = self
2488 .db
2489 .prepare("SELECT COUNT(*) AS steps, MAX(CASE WHEN step = ? THEN LENGTH(markdown) END) AS mine FROM job_summaries WHERE job_id = ?")
2490 .bind(&[step.into(), job.id.as_str().into()])?
2491 .first::<Held>(None)
2492 .await?;
2493 let (steps, mine) = held.map_or((0, None), |held| (held.steps, held.mine.map(|n| n as usize)));
2494 if summary_fits(steps, mine, markdown.len()) {
2495 self.db
2496 .prepare(
2497 "INSERT INTO job_summaries (job_id, step, markdown) VALUES (?, ?, ?)
2498 ON CONFLICT (job_id, step) DO UPDATE SET markdown = job_summaries.markdown || excluded.markdown",
2499 )
2500 .bind(&[job.id.as_str().into(), step.into(), markdown.into()])?
2501 .run()
2502 .await?;
2503 }
2504 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
2505 }
The balance shows what was spent this month and in total2506 "annotation" => {
2507 let mut annotations: Vec<Value> = serde_json::from_str(&job.annotations).unwrap_or_default();
2508 if annotations.len() < MAX_ANNOTATIONS {
2509 annotations.push(json!({
2510 "level": report["level"].as_str().unwrap_or("notice"),
2511 "message": report["message"].as_str().unwrap_or_default().chars().take(4000).collect::<String>(),
2512 "title": report["title"],
2513 "file": report["file"],
2514 "line": report["line"],
2515 }));
2516 self.db
2517 .prepare("UPDATE jobs SET annotations = ?, seen_at = ? WHERE id = ?")
2518 .bind(&[serde_json::to_string(&annotations)?.into(), at.as_str().into(), job.id.as_str().into()])?
2519 .run()
2520 .await?;
2521 }
2522 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2523 // Nothing to tell; the answer says whether to stop.
2524 "ping" => {
2525 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
2526 }
The balance shows what was spent this month and in total2527 "done" => {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2528 // A job told to stop ends cancelled, however its cleanup went.
2529 let conclusion = if job.cancel_requested_at.is_some() {
2530 "cancelled"
2531 } else {
2532 report["conclusion"]
2533 .as_str()
2534 .filter(|c| matches!(*c, "success" | "failure" | "cancelled"))
2535 .unwrap_or("failure")
2536 };
The balance shows what was spent this month and in total2537 let outputs = report["outputs"].as_object().cloned();
2538 Box::pin(self.finish_job(&job.id, conclusion, report["reason"].as_str(), outputs.as_ref())).await?;
2539 }
2540 other => return Ok(fail(FailureCode::Invalid, format!("There is no report called `{other}`."))),
2541 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2542 // `cancelled`: the run was cancelled, and the runner should stop
2543 // the step it is on and run only its cleanup steps.
2544 Ok(Outcome::Ok(json!({ "ok": true, "cancelled": job.cancel_requested_at.is_some() })))
The balance shows what was spent this month and in total2545 }
2546
2547 // --- Every minute ---------------------------------------------------------------
2548
2549 pub async fn on_minute(&self, now_ms: u64) -> Result<()> {
2550 let minute = now_ms / 60_000 * 60_000;
Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006)2551 // Staff (or billing's usage watcher) paused scheduled runs across
2552 // g1t: this minute's schedules are skipped, not queued for later.
2553 // Kept 30 seconds in the isolate (g1t_kit::pause).
2554 if g1t_kit::pause::paused(&self.billing, g1t_contracts::billing::PauseLevel::Schedules).await {
2555 worker::console_log!("actions: schedules are paused across g1t; skipped this minute's");
2556 } else if let Err(error) = self.run_schedules(minute).await {
The balance shows what was spent this month and in total2557 worker::console_error!("actions: schedules failed: {error}");
2558 }
2559 // Jobs whose sandbox went quiet or ran past their time.
2560 let running = self.db.prepare("SELECT * FROM jobs WHERE status = 'in_progress'").all().await?.results::<JobRow>()?;
2561 for job in running {
2562 // Times in g1t's format compare as text.
2563 let before = |ms: u64| rfc3339(now_ms.saturating_sub(ms));
2564 let silent = job.seen_at.as_deref().is_some_and(|seen| seen < before(SILENT_MS).as_str());
2565 let limit = (u64::from(job.timeout_minutes) * 60 + 120) * 1000;
2566 let over = job.started_at.as_deref().is_some_and(|started| started < before(limit).as_str());
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2567 let cancel_overdue = job.cancel_requested_at.as_deref().is_some_and(|at| at < before(CANCEL_GRACE_MS).as_str());
2568 if cancel_overdue {
2569 // Cancelled, and still going after its grace period.
2570 self.hard_stop(&job, "It was cancelled, and did not finish its cleanup steps within 5 minutes.").await?;
2571 self.advance(&job.run_id).await?;
2572 } else if over {
The balance shows what was spent this month and in total2573 let reason = format!("It ran longer than its time limit of {} minutes.", job.timeout_minutes);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2574 // A self-hosted runner is told to stop on its next poll.
2575 if job.runner_id.is_none() {
2576 let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await;
2577 }
The balance shows what was spent this month and in total2578 self.finish_job(&job.id, "failure", Some(&reason), None).await?;
2579 } else if silent {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2580 let reason = match &job.runner_name {
2581 Some(name) => format!("The self-hosted runner {name} stopped answering."),
2582 None => "The runner stopped answering.".to_owned(),
2583 };
2584 self.finish_job(&job.id, "failure", Some(&reason), None).await?;
The balance shows what was spent this month and in total2585 }
2586 }
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.2587 // Calls of reusable workflows waiting in a concurrency group, in
2588 // case the job they waited for was cancelled in another run.
2589 #[derive(Deserialize)]
2590 struct RunId {
2591 run_id: String,
2592 }
2593 let held = self
2594 .db
2595 .prepare("SELECT DISTINCT run_id FROM jobs WHERE status = 'waiting' AND concurrency_group IS NOT NULL LIMIT 20")
2596 .all()
2597 .await?
2598 .results::<RunId>()?;
2599 for run in held {
2600 self.advance(&run.run_id).await?;
2601 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2602 if let Err(error) = self.sweep_runners(now_ms).await {
2603 worker::console_error!("actions: the runners' sweep failed: {error}");
2604 }
Merge branch 'worktree-agent-a3abfcce648e87dca'2605 // Jobs held at an environment whose wait timer has run out.
2606 if let Err(error) = self.release_gates().await {
2607 worker::console_error!("actions: environments' gates failed: {error}");
2608 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2609 // Once an hour: the cache's expired entries, and its storage.
2610 if (now_ms / 60_000) % 60 == 7
2611 && let Err(error) = self.sweep_cache(now_ms).await
2612 {
2613 worker::console_error!("actions: the cache's sweep failed: {error}");
2614 }
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'2615 // And the archives of GitHub's actions no job uses any more.
2616 if (now_ms / 60_000) % 60 == 47
2617 && let Err(error) = self.sweep_action_archives(now_ms).await
2618 {
2619 worker::console_error!("actions: the action archives' sweep failed: {error}");
2620 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R22621 // And artifacts past their time, and the toolkit's abandoned parts.
2622 if (now_ms / 60_000) % 60 == 37 {
2623 if let Err(error) = self.sweep_artifacts(now_ms).await {
2624 worker::console_error!("actions: the artifacts' sweep failed: {error}");
2625 }
2626 if let Err(error) = self.sweep_blob_parts(now_ms).await {
2627 worker::console_error!("actions: the blob parts' sweep failed: {error}");
2628 }
2629 }
The balance shows what was spent this month and in total2630 self.start_queued().await
2631 }
2632}
2633
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2634
2635#[cfg(test)]
2636mod stopping {
2637 use super::stops_runs;
2638 use g1t_contracts::events::Event;
2639 use serde_json::{Value, json};
2640
2641 fn event(kind: &str, data: Value) -> Event {
2642 Event {
2643 id: "evt_1".into(),
2644 kind: kind.into(),
2645 source: "repos".into(),
2646 time: "2026-10-05T00:00:00Z".into(),
2647 repo_id: Some("rep_1".into()),
2648 actor: None,
2649 data,
2650 }
2651 }
2652
2653 #[test]
2654 fn deleting_or_archiving_stops_runs() {
2655 assert_eq!(stops_runs(&event("repo.deleted", json!({ "repoId": "rep_1" }))).as_deref(), Some("rep_1"));
2656 assert_eq!(stops_runs(&event("repo.archived", json!({ "archived": true }))).as_deref(), Some("rep_1"));
2657 assert_eq!(stops_runs(&event("repo.unarchived", json!({ "archived": false }))), None);
2658 assert_eq!(stops_runs(&event("repo.restored", json!({}))), None);
2659 assert_eq!(stops_runs(&event("git.push", json!({}))), None);
2660 }
2661}
Fast pages, required checks on the branch, self-hosted runners, honest incidents2662
2663#[cfg(test)]
2664mod status_of_needs {
2665 use std::collections::HashMap;
2666
2667 use super::ancestor_failed;
2668
2669 /// check -> plan -> (migrate) -> core -> edge, as deploy.yml has them,
2670 /// and a job that needs only the last.
2671 fn graph() -> HashMap<&'static str, Vec<&'static str>> {
2672 HashMap::from([
2673 ("check", vec![]),
2674 ("plan", vec!["check"]),
2675 ("migrate", vec!["plan"]),
2676 ("core", vec!["plan", "migrate"]),
2677 ("edge", vec!["plan", "migrate", "core"]),
2678 ("notify", vec!["edge"]),
2679 ])
2680 }
2681
2682 #[test]
2683 fn a_failure_is_seen_however_far_back() {
2684 let needs = graph();
2685 let failed = |which: &'static str| move |key: &str| key == which;
2686 // check failed; plan, and everything after, was skipped for it.
2687 assert!(ancestor_failed(&needs, "notify", failed("check")));
2688 assert!(ancestor_failed(&needs, "core", failed("check")));
2689 assert!(ancestor_failed(&needs, "edge", failed("core")));
2690 // Nothing before a job failed: a skipped migrate is not a failure.
2691 assert!(!ancestor_failed(&needs, "edge", |_| false));
2692 assert!(!ancestor_failed(&needs, "core", failed("edge")));
2693 assert!(!ancestor_failed(&needs, "check", failed("check")));
2694 }
2695
2696 #[test]
2697 fn cycles_and_unknown_keys_end() {
2698 let needs = HashMap::from([("a", vec!["b"]), ("b", vec!["a"])]);
2699 assert!(!ancestor_failed(&needs, "a", |_| false));
2700 assert!(!ancestor_failed(&needs, "missing", |_| true));
2701 }
2702}
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972703
2704#[cfg(test)]
2705mod deployments {
2706 use serde_json::{Map, Value, json};
2707
2708 use super::{JobEnvironment, deployment_outcome, environment_of};
2709
2710 #[test]
2711 fn a_jobs_environment_is_read_for_deployments() {
2712 let contexts: Map<String, Value> = serde_json::from_value(json!({
2713 "github": { "ref_name": "main", "repository": "acme/web" },
2714 "inputs": { "target": "staging" },
2715 "matrix": {},
2716 }))
2717 .unwrap();
2718 let read = |raw: Value| environment_of(&raw, &contexts);
2719 assert_eq!(read(json!({})), None);
2720 assert_eq!(
2721 read(json!({ "environment": "production" })),
2722 Some(JobEnvironment { name: "production".into(), url: None, deploys: true })
2723 );
2724 assert_eq!(
2725 read(json!({ "environment": { "name": "production", "url": "https://g1t.sh" } })),
2726 Some(JobEnvironment { name: "production".into(), url: Some("https://g1t.sh".into()), deploys: true })
2727 );
2728 // Expressions are filled in from the run.
2729 assert_eq!(
2730 read(json!({ "environment": { "name": "${{ inputs.target }}", "url": "https://${{ github.ref_name }}.example.com" } })),
2731 Some(JobEnvironment { name: "staging".into(), url: Some("https://main.example.com".into()), deploys: true })
2732 );
2733 // Secrets only: no deployment.
2734 assert!(!read(json!({ "environment": { "name": "production", "deployment": false } })).unwrap().deploys);
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.2735 // The same, said in the job's g1t settings.
2736 assert!(!read(json!({ "environment": "production", "g1t": { "deployment": false } })).unwrap().deploys);
2737 assert!(!read(json!({ "environment": { "name": "production" }, "g1t": { "deployment": false } })).unwrap().deploys);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972738 // Only http(s) addresses.
2739 assert_eq!(read(json!({ "environment": { "name": "production", "url": "javascript:alert(1)" } })).unwrap().url, None);
2740 }
2741
2742 #[test]
2743 fn a_runs_outcome_for_an_environment() {
2744 let of = |list: &[&str]| deployment_outcome(&list.iter().map(|c| Some((*c).to_owned())).collect::<Vec<_>>());
2745 assert_eq!(of(&["success", "skipped"]), Some("success"));
2746 assert_eq!(of(&["success", "failure"]), Some("failure"));
2747 assert_eq!(of(&["success", "cancelled"]), Some("error"));
2748 assert_eq!(of(&["skipped"]), None);
2749 assert_eq!(deployment_outcome(&[None]), None);
2750 }
2751}
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2752
2753#[cfg(test)]
2754mod reruns {
2755 use serde_json::{Map, Value, json};
2756
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.2757 use super::{MAX_SUMMARIES, MAX_SUMMARY_BYTES, Rerun, Scope, Status, call_allowed, call_prefix, debug_logging, rerun_keys, shown_name, summary_fits, top_key};
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2758
2759 /// build ← test ← deploy, and lint on its own.
2760 fn keys(which: Rerun, failed: &[&str]) -> Vec<String> {
2761 let order = ["build", "lint", "test", "deploy"];
2762 let needs = |key: &str| -> Vec<String> {
2763 match key {
2764 "test" => vec!["build".into()],
2765 "deploy" => vec!["test".into()],
2766 _ => Vec::new(),
2767 }
2768 };
2769 rerun_keys(&order, needs, |key| !failed.contains(&key), which)
2770 }
2771
2772 #[test]
2773 fn a_rerun_takes_the_jobs_it_names_and_those_that_need_them() {
2774 assert_eq!(keys(Rerun::All, &[]), ["build", "lint", "test", "deploy"]);
2775 assert_eq!(keys(Rerun::Failed, &["test"]), ["test", "deploy"]);
2776 assert_eq!(keys(Rerun::Failed, &["lint"]), ["lint"]);
2777 assert!(keys(Rerun::Failed, &[]).is_empty());
2778 // One job, whatever it came to, and what depends on it.
2779 assert_eq!(keys(Rerun::Job("build"), &[]), ["build", "test", "deploy"]);
2780 assert_eq!(keys(Rerun::Job("lint"), &["test"]), ["lint"]);
2781 assert_eq!(keys(Rerun::Job("deploy"), &[]), ["deploy"]);
2782 assert!(keys(Rerun::Job("missing"), &[]).is_empty());
2783 }
2784
2785 #[test]
2786 fn a_called_workflows_jobs_run_again_with_the_job_that_calls_it() {
2787 assert_eq!(top_key("build/test"), "build");
2788 assert_eq!(top_key("build/inner/test"), "build");
2789 assert_eq!(top_key("lint"), "lint");
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.2790 // A matrix's calls each have their own jobs, under `key[i]/`.
2791 assert_eq!(top_key("build[1]/test"), "build");
2792 assert_eq!(top_key("build/inner[0]/test"), "build");
2793 assert_eq!(call_prefix("build", Some(1)), "build[1]");
2794 assert_eq!(call_prefix("build/inner", None), "build/inner");
2795 }
2796
2797 #[test]
2798 fn a_run_connects_ten_levels_of_workflows_and_fifty_reusable_ones() {
2799 assert!(call_allowed(1, &[], "a.yml").is_ok());
2800 assert!(call_allowed(9, &[], "a.yml").is_ok());
2801 let deep = call_allowed(10, &[], "a.yml").unwrap_err();
2802 assert!(deep.contains("at most ten") && deep.contains("nine levels"), "{deep}");
2803 let names: Vec<String> = (0..50).map(|n| format!("w{n}.yml")).collect();
2804 let fifty: Vec<&str> = names.iter().map(String::as_str).collect();
2805 // A workflow called again, as by each combination of a matrix, is one.
2806 assert!(call_allowed(1, &fifty, "w3.yml").is_ok());
2807 assert!(call_allowed(1, &fifty, "another.yml").unwrap_err().contains("at most 50 different"));
2808 }
2809
2810 #[test]
2811 fn a_matrixs_calls_are_named_by_the_caller_and_its_values() {
2812 let workflow = super::workflow::parse(
2813 "on: push\njobs:\n build:\n name: Build\n strategy:\n matrix: { os: [linux], node: [20] }\n uses: ./.g1t/workflows/b.yml\n plain:\n uses: ./.g1t/workflows/b.yml\n test:\n name: Test ${{ matrix.os }}\n strategy:\n matrix: { os: [linux] }\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]",
2814 )
2815 .unwrap();
2816 let job = |id: &str| workflow.jobs.iter().find(|job| job.id == id).unwrap();
2817 let combination: Map<String, Value> = serde_json::from_value(json!({ "os": "linux", "node": 20 })).unwrap();
2818 let mut contexts = Map::new();
2819 contexts.insert("matrix".into(), Value::Object(combination.clone()));
2820 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
2821 assert_eq!(shown_name(job("build"), None, &combination, &scope), "Build (linux, 20)");
2822 assert_eq!(shown_name(job("plain"), None, &Map::new(), &scope), "plain");
2823 // A called workflow's job, under the call that made it.
2824 assert_eq!(shown_name(job("plain"), Some("Build (linux, 20)"), &Map::new(), &scope), "Build (linux, 20) / plain");
2825 assert_eq!(shown_name(job("test"), Some("Build"), &combination, &scope), "Build / Test linux");
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2826 }
2827
2828 #[test]
2829 fn a_job_keeps_twenty_steps_summaries_of_a_mebibyte_each() {
2830 assert!(summary_fits(0, None, 10));
2831 assert!(!summary_fits(0, None, 0), "nothing to keep");
2832 assert!(!summary_fits(MAX_SUMMARIES, None, 10), "a twenty-first step's summary is dropped");
2833 assert!(summary_fits(MAX_SUMMARIES, Some(10), 10), "a step that has one may add to it");
2834 assert!(summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 10));
2835 assert!(!summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 11));
2836 assert!(!summary_fits(0, None, MAX_SUMMARY_BYTES + 1));
2837 }
2838
2839 #[test]
2840 fn a_debug_rerun_sets_what_github_sets() {
2841 let mut variables = Map::new();
2842 let mut runner = json!({ "name": "g1t", "debug": "" });
2843 debug_logging(&mut variables, &mut runner);
2844 assert_eq!(variables["RUNNER_DEBUG"], "1");
2845 assert_eq!(variables["ACTIONS_STEP_DEBUG"], "true");
2846 assert_eq!(variables["ACTIONS_RUNNER_DEBUG"], "true");
2847 assert_eq!(runner["debug"], Value::String("1".into()));
2848 assert_eq!(runner["name"], "g1t");
2849 }
2850}

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