Skip to content
2,952 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.

Prices are what g1t pays plus 20%, from the first second1//! 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};
Prices are what g1t pays plus 20%, from the first second13use 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;
Prices are what g1t pays plus 20%, from the first second23use 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};
Prices are what g1t pays plus 20%, from the first second26
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;
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.32/// A batch of a job's log, under the next `seq` of that job's, worked out
Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r933/// in the same statement (SQLite runs one write at a time). `?4` is the
34/// runner's number for the batch (null from runners that send none): a
35/// batch sent again, after an answer the runner did not hear, has the
36/// number of one already kept, and is not kept twice (migration 0016).
37const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk)
38 SELECT ?1, COALESCE(MAX(seq), 0) + 1, ?2, ?3, ?4 FROM logs WHERE job_id = ?1
39 ON CONFLICT (job_id, chunk) DO NOTHING";
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)40/// The most one step's job summary keeps, in bytes, and how many steps of a
41/// job may have one, as on GitHub.
42pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
43pub(crate) const MAX_SUMMARIES: u32 = 20;
44
45/// Whether a step's summary of `adding` bytes is kept: a job keeps up to
46/// 20 steps' summaries (`steps` it has, `mine` this step's bytes so far)
47/// of up to 1 MiB each.
48pub(crate) fn summary_fits(steps: u32, mine: Option<usize>, adding: usize) -> bool {
49 if adding == 0 {
50 return false;
51 }
52 match mine {
53 Some(used) => used + adding <= MAX_SUMMARY_BYTES,
54 None => steps < MAX_SUMMARIES && adding <= MAX_SUMMARY_BYTES,
55 }
56}
Prices are what g1t pays plus 20%, from the first second57
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58/// The repository whose unfinished runs `event` stops, by id: one deleted,
59/// or archived (not unarchived).
60pub fn stops_runs(event: &g1t_contracts::events::Event) -> Option<String> {
61 let stops = match event.kind.as_str() {
62 "repo.deleted" => true,
63 "repo.archived" => event.data["archived"].as_bool() != Some(false),
64 _ => false,
65 };
66 if !stops {
67 return None;
68 }
69 event.repo_id.clone().or_else(|| event.data["repoId"].as_str().map(str::to_owned))
70}
71
Prices are what g1t pays plus 20%, from the first second72pub struct NewRun {
73 pub repo: Repo,
74 pub path: String,
75 pub source: String,
76 pub workflow: Workflow,
77 pub info: RunInfo,
78 pub action: Option<String>,
79 pub pull: Option<u32>,
80 pub title: String,
81 pub inputs: Map<String, Value>,
82 pub event_key: String,
83 pub actor_id: Option<String>,
84 pub actor: Option<String>,
85 pub trusted: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'86 /// Why the run waits for someone with the Write role to approve it
87 /// before anything starts: a pull request from outside (protection.rs).
88 pub approval: Option<String>,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.89 /// The commit the run is listed, and its statuses and checks reported,
90 /// on when it is not the one it builds (`info.sha`): a pull request's
91 /// head, when the run builds its test merge commit, as on GitHub.
92 pub head_sha: Option<String>,
Prices are what g1t pays plus 20%, from the first second93}
94
95#[derive(Clone, Deserialize)]
96pub struct RunRow {
97 pub id: String,
98 pub workflow_id: String,
99 pub repo_id: String,
100 pub repo: String,
101 pub path: String,
102 pub name: String,
103 pub title: String,
104 pub number: u64,
105 pub attempt: u64,
106 pub event: String,
107 pub action: Option<String>,
108 pub git_ref: String,
109 pub sha: String,
110 pub pull: Option<u32>,
111 pub status: String,
112 pub conclusion: Option<String>,
113 pub error: Option<String>,
114 pub actor: Option<String>,
115 pub actor_id: Option<String>,
116 pub source: String,
117 pub info: String,
118 pub inputs: String,
119 pub trusted: u32,
120 pub concurrency_group: Option<String>,
121 pub created_at: String,
122 pub started_at: Option<String>,
123 pub finished_at: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'124 /// JSON `RunApproval`, for a run that needed approval (migration 0006).
125 #[serde(default)]
126 pub approval: Option<String>,
127 /// Whether its concurrency group cancels what it replaces.
128 #[serde(default)]
129 pub cancel_in_progress: u32,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)130 /// Who started the current attempt, once it is re-run (migration 0009).
131 #[serde(default)]
132 pub triggering_actor: Option<String>,
133 /// The current attempt runs with debug logging.
134 #[serde(default)]
135 pub debug: u32,
Prices are what g1t pays plus 20%, from the first second136}
137
138impl RunRow {
Merge branch 'worktree-agent-a3abfcce648e87dca'139 pub fn approval(&self) -> Option<RunApproval> {
140 self.approval.as_deref().and_then(|text| serde_json::from_str(text).ok())
141 }
142
Prices are what g1t pays plus 20%, from the first second143 pub fn info(&self) -> RunInfo {
144 let mut info: RunInfo = serde_json::from_str(&self.info).unwrap_or_default();
145 info.run_id = self.id.clone();
146 info.run_number = self.number;
147 info.run_attempt = self.attempt;
148 info.workflow_path = self.path.clone();
149 if info.workflow.is_empty() {
150 info.workflow = self.name.clone();
151 }
152 info
153 }
154
155 pub fn inputs(&self) -> Map<String, Value> {
156 serde_json::from_str(&self.inputs).unwrap_or_default()
157 }
158
159 pub fn summary(&self) -> WorkflowRun {
160 WorkflowRun {
161 id: self.id.clone(),
162 workflow_id: self.workflow_id.clone(),
163 path: self.path.clone(),
164 name: self.name.clone(),
165 title: self.title.clone(),
166 number: self.number,
167 attempt: self.attempt,
168 event: self.event.clone(),
169 git_ref: self.git_ref.clone(),
170 sha: self.sha.clone(),
171 pull: self.pull,
172 status: self.status.clone(),
173 conclusion: self.conclusion.clone(),
174 error: self.error.clone(),
175 actor: self.actor.clone(),
176 created_at: self.created_at.clone(),
177 started_at: self.started_at.clone(),
178 finished_at: self.finished_at.clone(),
179 }
180 }
181}
182
183#[derive(Clone, Deserialize)]
184pub struct JobRow {
185 pub id: String,
186 pub run_id: String,
187 pub repo_id: String,
188 pub namespace: String,
189 pub key: String,
190 pub ordinal: u32,
191 pub name: String,
192 pub needs: String,
193 pub matrix: Option<String>,
194 pub status: String,
195 pub conclusion: Option<String>,
196 pub steps: String,
197 pub annotations: String,
198 pub outputs: String,
199 pub reason: Option<String>,
200 pub token_hash: Option<String>,
201 pub timeout_minutes: u32,
202 pub continue_on_error: u32,
203 pub max_parallel: Option<u32>,
204 /// Set for a job that calls a reusable workflow, and for that
205 /// workflow's jobs (see migration 0002).
206 pub call: Option<String>,
207 pub seen_at: Option<String>,
208 pub started_at: Option<String>,
209 pub finished_at: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents210 /// For a job whose `runs-on` names self-hosted runners: what it asks
211 /// for, as a JSON array (see `g1t_contracts::runners::Wanted`), when it
212 /// started waiting, and the runner that took it (migration 0004).
213 #[serde(default)]
214 pub labels: Option<String>,
215 #[serde(default)]
216 pub queued_at: Option<String>,
217 #[serde(default)]
218 pub runner_id: Option<String>,
219 #[serde(default)]
220 pub runner_name: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'221 /// The environment it names, read when its needs were done; a job its
222 /// rules hold is `pending` (migration 0006).
223 #[serde(default)]
224 pub environment: Option<String>,
225 /// Its own `concurrency` group, and whether that cancels what it replaces.
226 #[serde(default)]
227 pub concurrency_group: Option<String>,
228 #[serde(default)]
229 pub cancel_in_progress: u32,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)230 /// When it was told to stop, while it runs its cleanup steps
231 /// (migration 0009).
232 #[serde(default)]
233 pub cancel_requested_at: Option<String>,
234}
235
236/// How long a cancelled job has to run its `if: always()` and `cancelled()`
237/// steps and its post steps before it is stopped outright, as on GitHub.
238pub const CANCEL_GRACE_MS: u64 = 5 * 60 * 1000;
239
240/// Which jobs a re-run runs again.
241#[derive(Clone, Copy, Debug, PartialEq)]
242pub enum Rerun<'a> {
243 All,
244 /// Those that did not succeed.
245 Failed,
246 /// One job's key.
247 Job(&'a str),
248}
249
250/// The keys a re-run runs again, in `order`: those `which` names and,
251/// transitively, every key that needs one of them. `needs` gives a key's
252/// needs; `succeeded` whether every job of a key succeeded.
253pub fn rerun_keys(order: &[&str], needs: impl Fn(&str) -> Vec<String>, succeeded: impl Fn(&str) -> bool, which: Rerun) -> Vec<String> {
254 let mut again: Vec<String> = Vec::new();
255 for key in order {
256 let named = match which {
257 Rerun::All => true,
258 Rerun::Failed => !succeeded(key),
259 Rerun::Job(job) => *key == job,
260 };
261 if named || needs(key).iter().any(|need| again.contains(need)) {
262 again.push((*key).to_owned());
263 }
264 }
265 again
266}
267
268/// The key under `jobs:` a job belongs to: a called workflow's jobs
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9269/// (`build/test`, or `build[1]/test` for a matrix's second call) run again
270/// with the job that calls it (`build`).
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)271pub fn top_key(key: &str) -> &str {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9272 let first = key.split('/').next().unwrap_or(key);
273 first.split('[').next().unwrap_or(first)
274}
275
276/// What the keys of the jobs a caller's call makes start with: the
277/// caller's key, and for a matrix the combination's index after it
278/// (`build[1]`), so each combination's call has jobs of its own. A job id
279/// has no `[`, so the two never meet.
280fn call_prefix(key: &str, index: Option<usize>) -> String {
281 match index {
282 Some(index) => format!("{key}[{index}]"),
283 None => key.to_owned(),
284 }
285}
286
287/// Whether a run may call one more reusable workflow, `file`, `depth`
288/// levels below the workflow it started from, having called `called`
289/// already (one name per call; repeats are one workflow). As on GitHub: ten
290/// levels of workflows at most, the top one and nine reusable ones below
291/// it, and 50 different reusable workflows in a run.
292fn call_allowed(depth: u64, called: &[&str], file: &str) -> std::result::Result<(), String> {
293 if depth > MAX_CALL_DEPTH {
294 return Err(format!(
295 "`{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.",
296 depth + 1
297 ));
298 }
299 let mut unique: Vec<&str> = called.to_vec();
300 unique.push(file);
301 unique.sort_unstable();
302 unique.dedup();
303 if unique.len() > MAX_CALLED_WORKFLOWS {
304 return Err(format!("Calling `{file}` would make {} reusable workflows in this run: a run calls at most {MAX_CALLED_WORKFLOWS} different ones.", unique.len()));
305 }
306 Ok(())
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)307}
308
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9309/// What a job is shown as: its `name` (else its id), under the job whose
310/// call made it (`caller`), read with its matrix when it has an
311/// expression, else with the matrix's values after it.
312fn shown_name(job: &workflow::Job, caller: Option<&str>, combination: &Map<String, Value>, scope: &Scope) -> String {
313 let base = job.name.clone().unwrap_or(job.id.clone());
314 let base = match caller {
315 Some(caller) => format!("{caller} / {base}"),
316 None => base,
317 };
318 if expr::has_expression(&base) {
319 expr::interpolate(&base, scope).unwrap_or(base)
320 } else if job.matrix.is_some() {
321 matrix::job_name(&base, combination)
322 } else {
323 base
324 }
325}
326
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)327/// Debug logging for a job, as a re-run with it turned on gives GitHub's:
328/// `RUNNER_DEBUG=1` and `runner.debug`, and `ACTIONS_STEP_DEBUG` and
329/// `ACTIONS_RUNNER_DEBUG` set, which the runner reads as the secrets of
330/// those names (`::debug::` lines shown, and how each step's `if` read).
331pub fn debug_logging(variables: &mut Map<String, Value>, runner: &mut Value) {
332 variables.insert("RUNNER_DEBUG".into(), json!("1"));
333 variables.insert("ACTIONS_STEP_DEBUG".into(), json!("true"));
334 variables.insert("ACTIONS_RUNNER_DEBUG".into(), json!("true"));
335 runner["debug"] = json!("1");
Prices are what g1t pays plus 20%, from the first second336}
337
338impl JobRow {
339 pub fn needs(&self) -> Vec<String> {
340 serde_json::from_str(&self.needs).unwrap_or_default()
341 }
342
343 pub fn call(&self) -> Option<Value> {
344 self.call.as_deref().and_then(|call| serde_json::from_str(call).ok())
345 }
346
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9347 /// For a job that calls a reusable workflow: what the keys of the jobs
348 /// its call makes start with (`call_prefix`).
349 pub fn call_prefix(&self) -> Option<String> {
350 let call = self.call().filter(|call| call["role"] == "caller")?;
351 Some(call["prefix"].as_str().unwrap_or(&self.key).to_owned())
352 }
353
354 /// A job that calls a reusable workflow and has not called it yet: it
355 /// waits for room under its matrix's `max-parallel`, or in its
356 /// concurrency group.
357 pub fn holds_call(&self) -> bool {
358 self.status == "waiting" && self.call_prefix().is_some()
359 }
360
Prices are what g1t pays plus 20%, from the first second361 /// For a job of a called workflow: that workflow, the job's own id in
362 /// it, and the job.
363 pub fn callee(&self) -> Option<(Workflow, workflow::Job, Value)> {
364 let call = self.call().filter(|call| call["role"] == "callee")?;
365 let called = workflow::parse(call["source"].as_str()?).ok()?;
366 let job = called.jobs.iter().find(|job| call["job"].as_str() == Some(job.id.as_str()))?.clone();
367 Some((called, job, call))
368 }
369}
370
371/// A job to decide on: its key, its definition, and what it needs, as
372/// (name in `needs`, key of the jobs).
373type Unit = (String, workflow::Job, Vec<(String, String)>);
374
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9375/// How many levels of reusable workflows may sit below the workflow a run
376/// started from, as on GitHub (ten levels of workflows in all).
377const MAX_CALL_DEPTH: u64 = 9;
378/// How many different reusable workflows a run may call, as on GitHub.
379const MAX_CALLED_WORKFLOWS: usize = 50;
Prices are what g1t pays plus 20%, from the first second380
381/// What the jobs of one key came to, for `needs.<key>`.
382fn key_result(rows: &[&JobRow]) -> &'static str {
383 let failed = |row: &&&JobRow| row.conclusion.as_deref() == Some("failure") && row.continue_on_error == 0;
384 if rows.iter().any(|row| failed(&row)) {
385 "failure"
386 } else if rows.iter().any(|row| row.conclusion.as_deref() == Some("cancelled")) {
387 "cancelled"
388 } else if rows.iter().all(|row| row.conclusion.as_deref() == Some("skipped")) {
389 "skipped"
390 } else {
391 "success"
392 }
393}
394
Fast pages, required checks on the branch, self-hosted runners, honest incidents395/// Whether any job before `key` failed: one it needs, or one those need,
396/// however far back. A job after a skipped one still sees the failure
397/// that skipped it, as GitHub's failure() does. `needs_of`: each key's
398/// needs, as keys; `failed`: whether a key's jobs came to a failure.
399fn ancestor_failed(needs_of: &std::collections::HashMap<&str, Vec<&str>>, key: &str, failed: impl Fn(&str) -> bool) -> bool {
400 let mut seen = std::collections::HashSet::new();
401 let mut stack: Vec<&str> = needs_of.get(key).cloned().unwrap_or_default();
402 while let Some(next) = stack.pop() {
403 if !seen.insert(next) {
404 continue;
405 }
406 if failed(next) {
407 return true;
408 }
409 stack.extend(needs_of.get(next).into_iter().flatten().copied());
410 }
411 false
412}
413
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97414/// A job's `environment:`, as deployments read it: its name, the address in
415/// `environment.url` once its expressions are filled in from the run, and
416/// whether the job deploys to it. A job with `deployment: false` only reads
417/// the environment's secrets and variables, and makes no deployment.
418#[derive(Clone, Debug, Default, PartialEq)]
419pub(crate) struct JobEnvironment {
420 pub(crate) name: String,
421 pub(crate) url: Option<String>,
422 pub(crate) deploys: bool,
423}
424
425/// The environment `environment:` names, as written in `raw` (a job), with
426/// `contexts` to fill in expressions: null when it names none, or only
427/// through an expression this cannot read before the job runs.
428pub(crate) fn environment_of(raw: &Value, contexts: &Map<String, Value>) -> Option<JobEnvironment> {
429 let scope = Scope { contexts, status: Status::Success, hash_files: None };
430 let plain = |value: &Value| -> Option<String> {
431 let text = match value {
432 Value::String(text) if text.contains("${{") => expr::interpolate_value(value, &scope).ok().map(|v| expr::to_text(&v))?,
433 Value::String(text) => text.clone(),
434 _ => return None,
435 };
436 let text = text.trim().to_owned();
437 (!text.is_empty()).then_some(text)
438 };
439 match raw.get("environment")? {
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9440 name @ Value::String(_) => Some(JobEnvironment { name: plain(name)?, url: None, deploys: workflow::records_deployment(raw) }),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97441 Value::Object(env) => {
442 let name = plain(env.get("name")?)?;
443 let url = env
444 .get("url")
445 .and_then(plain)
446 .filter(|url| url.starts_with("https://") || url.starts_with("http://"));
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9447 Some(JobEnvironment { name, url, deploys: workflow::records_deployment(raw) })
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97448 }
449 _ => None,
450 }
451}
452
453/// The contexts a job's `runs-on` and `environment` are read with before it
454/// runs: the run's `github`, its inputs, and the job's matrix.
455fn start_contexts(run: &RunRow, job: &JobRow, key: &str) -> Map<String, Value> {
456 let mut contexts = Map::new();
457 contexts.insert("github".into(), run.info().context(key, "", run.action.as_deref()));
458 contexts.insert("inputs".into(), Value::Object(run.inputs()));
459 contexts.insert("matrix".into(), job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or_else(|| json!({})));
460 contexts
461}
462
463/// The job as its workflow (or the workflow it calls) defines it.
464fn job_spec(run: &RunRow, job: &JobRow) -> Option<workflow::Job> {
465 match job.callee() {
466 Some((_, spec, _)) => Some(spec),
467 None => workflow::parse(&run.source).ok().and_then(|w| w.jobs.into_iter().find(|j| j.id == job.key)),
468 }
469}
470
Merge branch 'worktree-agent-a3abfcce648e87dca'471/// The environment a job deploys to, if it deploys: by the name read when
472/// its needs were done (which may have needed their outputs), else as
473/// its `environment:` reads now.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97474pub(crate) fn deploys_to(run: &RunRow, job: &JobRow) -> Option<JobEnvironment> {
475 let spec = job_spec(run, job)?;
Merge branch 'worktree-agent-a3abfcce648e87dca'476 let read = environment_of(&spec.raw, &start_contexts(run, job, &spec.id));
477 let env = match (read, &job.environment) {
478 (Some(env), Some(name)) => Some(JobEnvironment { name: name.clone(), ..env }),
479 (Some(env), None) => Some(env),
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r9480 (None, Some(name)) => Some(JobEnvironment { name: name.clone(), url: None, deploys: workflow::records_deployment(&spec.raw) }),
Merge branch 'worktree-agent-a3abfcce648e87dca'481 (None, None) => None,
482 };
483 env.filter(|env| env.deploys)
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97484}
485
Fast pages, required checks on the branch, self-hosted runners, honest incidents486/// What the runner is told about a job it starts, from its workflow: the
487/// environment it names plainly, and the machine its `runs-on` asks for
488/// (`instance_for`; none for the standard one).
489#[derive(Default)]
490struct StartDetails {
491 environment: Option<String>,
492 instance: Option<String>,
493}
494
495fn start_details(run: &RunRow, job: &JobRow) -> StartDetails {
496 let spec = match job.callee() {
497 Some((_, spec, _)) => spec,
498 None => match workflow::parse(&run.source).ok().and_then(|w| w.jobs.into_iter().find(|j| j.id == job.key)) {
499 Some(spec) => spec,
500 None => return StartDetails::default(),
501 },
502 };
Merge branch 'worktree-agent-a3abfcce648e87dca'503 // As read when its needs were done, an expression's included.
504 let environment = job.environment.clone();
Fast pages, required checks on the branch, self-hosted runners, honest incidents505 // `runs-on` as the job was queued with: its matrix and the run's
506 // inputs. A label that needs more than those is the standard machine.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97507 let contexts = start_contexts(run, job, &spec.id);
Fast pages, required checks on the branch, self-hosted runners, honest incidents508 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
509 let labels: Vec<String> = match expr::interpolate_value(&spec.runs_on, &scope).unwrap_or(Value::Null) {
510 Value::String(label) => vec![label],
511 Value::Array(labels) => labels.iter().map(expr::to_text).collect(),
512 Value::Object(given) => match given.get("labels") {
513 Some(Value::Array(labels)) => labels.iter().map(expr::to_text).collect(),
514 Some(label) => vec![expr::to_text(label)],
515 None => Vec::new(),
516 },
517 _ => Vec::new(),
518 };
519 let instance = g1t_contracts::actions::instance_for(&labels);
520 StartDetails {
521 environment,
522 instance: (instance != g1t_contracts::actions::STANDARD_INSTANCE).then(|| instance.label.to_owned()),
523 }
524}
525
Prices are what g1t pays plus 20%, from the first second526fn now() -> String {
527 rfc3339(now_ms())
528}
529
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97530/// How a finished run went for one environment, from the conclusions of
531/// the jobs that deploy to it: failed if any failed, an error if any was
532/// cancelled, else a success; nothing when none ran (all skipped).
533pub(crate) fn deployment_outcome(conclusions: &[Option<String>]) -> Option<&'static str> {
534 let ran: Vec<&str> = conclusions.iter().flatten().map(String::as_str).filter(|c| *c != "skipped").collect();
535 if ran.is_empty() {
536 return None;
537 }
538 if ran.iter().any(|c| *c == "failure" || *c == "timed_out") {
539 return Some("failure");
540 }
541 if ran.contains(&"cancelled") {
542 return Some("error");
543 }
544 Some("success")
545}
546
Prices are what g1t pays plus 20%, from the first second547impl Actions {
548 pub async fn run_row(&self, id: &str) -> Result<Option<RunRow>> {
549 self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<RunRow>(None).await
550 }
551
552 pub async fn job_rows(&self, run_id: &str) -> Result<Vec<JobRow>> {
553 self.db
554 .prepare("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid")
555 .bind(&[run_id.into()])?
556 .all()
557 .await?
558 .results::<JobRow>()
559 }
560
561 pub async fn run_summary(&self, id: &str) -> Result<Outcome<WorkflowRun>> {
562 Ok(match self.run_row(id).await? {
563 Some(row) => Outcome::Ok(row.summary()),
564 None => fail(FailureCode::NotFound, "No such run."),
565 })
566 }
567
568 /// The contexts every expression outside a job's steps may use.
569 fn base_contexts(run: &RunRow, vars: &Map<String, Value>, job: &str) -> Map<String, Value> {
570 let mut contexts = Map::new();
571 contexts.insert("github".into(), run.info().context(job, "", run.action.as_deref()));
572 contexts.insert("inputs".into(), Value::Object(run.inputs()));
573 contexts.insert("vars".into(), Value::Object(vars.clone()));
574 contexts.insert("needs".into(), json!({}));
575 contexts.insert("runner".into(), runner_context());
576 contexts
577 }
578
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.579 /// Whether `namespace` is a person's own workspace: usernames and
580 /// workspaces share one namespace, so it is when someone has that
581 /// username. Kept in the isolate for a few minutes, so a burst of
582 /// runs asks identity once.
583 async fn personal_namespace(&self, namespace: &str) -> Result<bool> {
584 const KEEP_MS: u64 = 5 * 60 * 1000;
585 thread_local! {
586 static PERSONAL: std::cell::RefCell<std::collections::HashMap<String, (bool, u64)>> = Default::default();
587 }
588 let now = now_ms();
589 let key = namespace.to_ascii_lowercase();
590 if let Some(personal) = PERSONAL.with(|kept| kept.borrow().get(&key).filter(|(_, at)| now.saturating_sub(*at) < KEEP_MS).map(|(personal, _)| *personal)) {
591 return Ok(personal);
592 }
593 let found: g1t_contracts::Viewer = g1t_kit::call(
594 &self.identity,
595 "user_by_username",
596 &g1t_contracts::identity::UsernameArgs { username: key.clone() },
597 )
598 .await?;
599 let personal = found.is_some_and(|user| user.username.eq_ignore_ascii_case(namespace));
600 PERSONAL.with(|kept| {
601 let mut kept = kept.borrow_mut();
602 if kept.len() > 1000 {
603 kept.clear();
604 }
605 kept.insert(key, (personal, now));
606 });
607 Ok(personal)
608 }
609
Prices are what g1t pays plus 20%, from the first second610 /// Makes a run and its jobs, and starts what can start. `None` when the
611 /// event already started this workflow.
612 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 look613 // Nothing starts on an archived repository. A deleted one is never
614 // found to start anything on.
615 if new.repo.archived() {
616 return Ok(None);
617 }
Prices are what g1t pays plus 20%, from the first second618 let workflow_row = self.workflow_row(&new.repo, &new.path, &new.workflow.display_name(&new.path), &new.source).await?;
619 let numbered = self
620 .db
621 .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n")
622 .bind(&[workflow_row.id.as_str().into()])?
623 .first::<Count>(None)
624 .await?
625 .map_or(1, |count| count.n);
626 let id = new_id("run", now_ms());
627 let mut info = new.info.clone();
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.628 // A personal workspace's repositories are a `User`'s, as on GitHub.
629 if self.personal_namespace(&new.repo.namespace).await? {
630 crate::payload::owned_by_person(&mut info.event, &new.repo.namespace);
631 }
Prices are what g1t pays plus 20%, from the first second632 info.workflow = new.workflow.display_name(&new.path);
633 info.workflow_path = new.path.clone();
634 info.run_id = id.clone();
635 info.run_number = u64::from(numbered);
636 let vars = self
637 .variables_for(&new.repo.id, &format!("{}/{}", new.repo.namespace, new.repo.name), None, new.trusted)
638 .await?;
639
640 // run-name and the concurrency group read github, inputs and vars.
641 let mut contexts = Map::new();
642 contexts.insert("github".into(), info.context("", "", new.action.as_deref()));
643 contexts.insert("inputs".into(), Value::Object(new.inputs.clone()));
644 contexts.insert("vars".into(), Value::Object(vars));
645 let scope = Scope {
646 contexts: &contexts,
647 status: Status::Success,
648 hash_files: None,
649 };
650 let title = new
651 .workflow
652 .run_name
653 .as_deref()
654 .and_then(|run_name| expr::interpolate(run_name, &scope).ok())
655 .filter(|title| !title.trim().is_empty())
656 .unwrap_or(new.title.clone());
657 let group = new.workflow.concurrency.as_ref().and_then(|c| expr::interpolate(&c.group, &scope).ok());
658 let cancel_in_progress = new
659 .workflow
660 .concurrency
661 .as_ref()
662 .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok())
663 .is_some_and(|value| expr::truthy(&value));
664
Merge branch 'worktree-agent-a3abfcce648e87dca'665 let approval = new.approval.as_ref().map(|reason| RunApproval { state: "required".into(), reason: reason.clone(), approved_by: None });
Prices are what g1t pays plus 20%, from the first second666 let inserted = self
667 .db
668 .prepare(
669 "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'670 pull, status, actor, actor_id, source, info, inputs, trusted, concurrency_group, event_key, created_at,
671 approval, cancel_in_progress)
672 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
Prices are what g1t pays plus 20%, from the first second673 )
674 .bind(&[
675 id.as_str().into(),
676 workflow_row.id.as_str().into(),
677 new.repo.id.as_str().into(),
678 format!("{}/{}", new.repo.namespace, new.repo.name).into(),
679 new.path.as_str().into(),
680 info.workflow.as_str().into(),
681 title.as_str().into(),
682 numbered.into(),
683 info.event_name.as_str().into(),
684 optional(new.action.as_deref()),
685 info.git_ref.as_str().into(),
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.686 new.head_sha.as_deref().unwrap_or(&info.sha).into(),
Prices are what g1t pays plus 20%, from the first second687 new.pull.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
Merge branch 'worktree-agent-a3abfcce648e87dca'688 if approval.is_some() { "action_required" } else { "queued" }.into(),
Prices are what g1t pays plus 20%, from the first second689 optional(new.actor.as_deref()),
690 optional(new.actor_id.as_deref()),
691 new.source.as_str().into(),
692 serde_json::to_string(&info)?.into(),
693 serde_json::to_string(&new.inputs)?.into(),
694 u32::from(new.trusted).into(),
695 optional(group.as_deref()),
696 new.event_key.as_str().into(),
697 now().into(),
Merge branch 'worktree-agent-a3abfcce648e87dca'698 approval.as_ref().map(serde_json::to_string).transpose()?.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
699 u32::from(cancel_in_progress).into(),
Prices are what g1t pays plus 20%, from the first second700 ])?
701 .first::<Value>(None)
702 .await?;
703 if inserted.is_none() {
704 return Ok(None);
705 }
706 if let Some(run) = self.run_row(&id).await? {
707 self.report_pending(&run).await?;
708 }
709
710 // Every job, waiting; each is expanded when the jobs it needs are done.
711 let mut statements = Vec::new();
712 for job in &new.workflow.jobs {
713 statements.push(
714 self.db
715 .prepare(
716 "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting')",
717 )
718 .bind(&[
719 new_id("job", now_ms()).into(),
720 id.as_str().into(),
721 new.repo.id.as_str().into(),
722 new.repo.namespace.as_str().into(),
723 job.id.as_str().into(),
724 job.name.clone().filter(|n| !expr::has_expression(n)).unwrap_or(job.id.clone()).into(),
725 serde_json::to_string(&job.needs)?.into(),
726 ])?,
727 );
728 }
729 self.db.batch(statements).await?;
730
Merge branch 'worktree-agent-a3abfcce648e87dca'731 // A pull request's run from outside waits to be approved; it joins
732 // its concurrency group once it is (protection.rs, approve_run).
733 if approval.is_some() {
734 return Ok(Some(id));
735 }
736 if let Some(run) = self.run_row(&id).await? {
737 self.enter_group(&run).await?;
738 }
739 Ok(Some(id))
740 }
741
742 /// A run about to start: one run at a time per concurrency group, as on
743 /// GitHub. A newer run replaces one that waits in the group, and with
744 /// `cancel-in-progress` the one that runs; otherwise it waits as
745 /// `pending` for the one that runs. Then it moves along.
746 pub(crate) async fn enter_group(&self, run: &RunRow) -> Result<()> {
747 if let Some(group) = &run.concurrency_group {
748 let cancel_in_progress = run.cancel_in_progress != 0;
Prices are what g1t pays plus 20%, from the first second749 let others = self
750 .db
Merge branch 'worktree-agent-a3abfcce648e87dca'751 .prepare(
752 "SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND id != ? AND status NOT IN ('completed', 'action_required') ORDER BY id",
753 )
754 .bind(&[run.repo_id.as_str().into(), group.as_str().into(), run.id.as_str().into()])?
Prices are what g1t pays plus 20%, from the first second755 .all()
756 .await?
757 .results::<RunRow>()?;
758 for other in &others {
759 if cancel_in_progress || other.status == "pending" {
760 // 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)761 self.cancel_run(other, "A newer run in the same concurrency group replaced it.", false).await?;
Prices are what g1t pays plus 20%, from the first second762 }
763 }
764 if !cancel_in_progress && others.iter().any(|other| other.status != "pending") {
765 self.db
766 .prepare("UPDATE runs SET status = 'pending' WHERE id = ?")
Merge branch 'worktree-agent-a3abfcce648e87dca'767 .bind(&[run.id.as_str().into()])?
Prices are what g1t pays plus 20%, from the first second768 .run()
769 .await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'770 return Ok(());
Prices are what g1t pays plus 20%, from the first second771 }
772 }
Merge branch 'worktree-agent-a3abfcce648e87dca'773 self.advance(&run.id).await
Prices are what g1t pays plus 20%, from the first second774 }
775
776 /// A run that could not start, such as for a workflow file that does not read.
777 #[allow(clippy::too_many_arguments)]
778 pub async fn record_failed_run(
779 &self,
780 row: &WorkflowRow,
781 git_ref: &str,
782 sha: &str,
783 event_key: &str,
784 actor_id: Option<&str>,
785 actor: &str,
786 problem: &str,
787 ) -> Result<()> {
788 let numbered = self
789 .db
790 .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n")
791 .bind(&[row.id.as_str().into()])?
792 .first::<Count>(None)
793 .await?
794 .map_or(1, |count| count.n);
795 let at = now();
796 self.db
797 .prepare(
798 "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, git_ref, sha, status,
799 conclusion, error, actor, actor_id, source, info, event_key, created_at, finished_at)
800 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'push', ?, ?, 'completed', 'failure', ?, ?, ?, ?, '{}', ?, ?, ?)",
801 )
802 .bind(&[
803 new_id("run", now_ms()).into(),
804 row.id.as_str().into(),
805 row.repo_id.as_str().into(),
806 row.repo.as_str().into(),
807 row.path.as_str().into(),
808 row.path.as_str().into(),
809 "Invalid workflow file".into(),
810 numbered.into(),
811 git_ref.into(),
812 sha.into(),
813 problem.into(),
814 actor.into(),
815 optional(actor_id),
816 row.source.as_str().into(),
817 event_key.into(),
818 at.as_str().into(),
819 at.as_str().into(),
820 ])?
821 .run()
822 .await?;
823 Ok(())
824 }
825
826 /// Moves a run along: jobs whose needs are done are decided on, and
827 /// jobs that can start are started.
828 pub async fn advance(&self, run_id: &str) -> Result<()> {
829 // Each pass may finish jobs (skipped ones), which may free others.
830 for _ in 0..20 {
831 let Some(run) = self.run_row(run_id).await? else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'832 if matches!(run.status.as_str(), "completed" | "pending" | "action_required") {
Prices are what g1t pays plus 20%, from the first second833 return Ok(());
834 }
835 let workflow = match workflow::parse(&run.source) {
836 Ok(workflow) => workflow,
837 Err(problem) => {
838 self.finish_run(&run, Some(&problem)).await?;
839 return Ok(());
840 }
841 };
842 let jobs = self.job_rows(run_id).await?;
843 let mut changed = false;
844 // What to decide on: the workflow's jobs, and the jobs of the
845 // workflows they call, each with its needs as (name, key).
846 let mut units: Vec<Unit> = workflow
847 .jobs
848 .iter()
849 .map(|job| (job.id.clone(), job.clone(), job.needs.iter().map(|n| (n.clone(), n.clone())).collect()))
850 .collect();
851 let mut seen = std::collections::HashSet::new();
852 for row in &jobs {
853 if !seen.insert(row.key.clone()) {
854 continue;
855 }
856 if let Some((_, job, call)) = row.callee() {
857 let parent = call["parent"].as_str().unwrap_or_default().to_owned();
858 let needs = job.needs.iter().map(|n| (n.clone(), format!("{parent}/{n}"))).collect();
859 units.push((row.key.clone(), job, needs));
860 }
861 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents862 // Each key's needs, as keys, to look back through for failure().
863 let needs_of: std::collections::HashMap<&str, Vec<&str>> =
864 units.iter().map(|(key, _, needs)| (key.as_str(), needs.iter().map(|(_, need)| need.as_str()).collect())).collect();
865 let key_failed = |key: &str| key_result(&jobs.iter().filter(|row| row.key == key).collect::<Vec<_>>()) == "failure";
Prices are what g1t pays plus 20%, from the first second866 for (key, job, needs) in &units {
867 let rows: Vec<&JobRow> = jobs.iter().filter(|row| &row.key == key).collect();
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9868 if rows.is_empty() || !rows.iter().all(|row| row.status == "waiting" && !row.holds_call()) {
Prices are what g1t pays plus 20%, from the first second869 continue;
870 }
871 let needed: Vec<(&String, Vec<&JobRow>)> =
872 needs.iter().map(|(name, need)| (name, jobs.iter().filter(|row| &row.key == need).collect())).collect();
873 if !needed.iter().all(|(_, rows)| rows.iter().all(|row| row.status == "completed")) {
874 continue;
875 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents876 let failed_before = ancestor_failed(&needs_of, key, key_failed);
877 self.decide(&run, job, rows[0], &needed, failed_before).await?;
Prices are what g1t pays plus 20%, from the first second878 changed = true;
879 }
880 // A job that called a workflow finishes with that workflow's jobs.
881 for row in jobs.iter().filter(|row| row.status == "calling") {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9882 let prefix = row.call_prefix().unwrap_or(row.key.clone());
Prices are what g1t pays plus 20%, from the first second883 let children: Vec<&JobRow> = jobs
884 .iter()
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9885 .filter(|child| child.call().is_some_and(|call| call["role"] == "callee" && call["parent"].as_str() == Some(prefix.as_str())))
Prices are what g1t pays plus 20%, from the first second886 .collect();
887 if !children.is_empty() && children.iter().all(|child| child.status == "completed") {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9888 self.finish_call(&run, row, &children).await?;
Prices are what g1t pays plus 20%, from the first second889 changed = true;
890 }
891 }
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9892 // Calls that wait start while their matrix's `max-parallel` and
893 // their concurrency group have room.
894 let mut calling: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
895 for row in jobs.iter().filter(|row| row.status == "calling") {
896 *calling.entry(row.key.as_str()).or_default() += 1;
897 }
898 for row in jobs.iter().filter(|row| row.holds_call()) {
899 let running = calling.get(row.key.as_str()).copied().unwrap_or(0);
900 if row.max_parallel.is_some_and(|max| running >= max) || self.group_busy(row).await? {
901 continue;
902 }
903 if self.start_call(row).await? {
904 calling.insert(row.key.as_str(), running + 1);
905 changed = true;
906 }
907 }
Prices are what g1t pays plus 20%, from the first second908 if !changed {
909 break;
910 }
911 }
912 self.start_queued().await?;
913 self.finish_if_done(run_id).await
914 }
915
916 /// 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 incidents917 /// it into its matrix and queue it. `failed_before`: whether any job
918 /// before it failed, however far back (`ancestor_failed`).
919 async fn decide(&self, run: &RunRow, job: &workflow::Job, row: &JobRow, needed: &[(&String, Vec<&JobRow>)], failed_before: bool) -> Result<()> {
Prices are what g1t pays plus 20%, from the first second920 let vars = self.variables_for(&run.repo_id, &run.repo, None, run.trusted != 0).await?;
921 let mut contexts = Self::base_contexts(run, &vars, &job.id);
922 // A called workflow's jobs read the inputs they were called with.
923 let call = row.call();
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9924 // Shown under the job whose call made it, by that job's name.
925 let caller = call.as_ref().filter(|c| c["role"] == "callee").and_then(|c| {
926 c["title"].as_str().map(str::to_owned).or_else(|| c["parent"].as_str().map(|parent| parent.replace('/', " / ")))
927 });
Prices are what g1t pays plus 20%, from the first second928 if let Some(call) = call.as_ref().filter(|c| c["role"] == "callee") {
929 contexts.insert("inputs".into(), call["inputs"].clone());
930 }
931 let mut needs = Map::new();
Fast pages, required checks on the branch, self-hosted runners, honest incidents932 let mut results = Vec::new();
Prices are what g1t pays plus 20%, from the first second933 for (key, rows) in needed {
934 let result = key_result(rows);
935 let mut outputs = Map::new();
936 for row in rows {
937 if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) {
938 outputs.extend(more);
939 }
940 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents941 results.push(result);
Prices are what g1t pays plus 20%, from the first second942 needs.insert((*key).clone(), json!({ "result": result, "outputs": outputs }));
943 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents944 // As on GitHub: a need that was skipped makes success() false but
945 // not failure(); failure() is a failure anywhere before the job.
946 let status = expr::job_status(results, failed_before, run.conclusion.as_deref() == Some("cancelled"));
Prices are what g1t pays plus 20%, from the first second947 contexts.insert("needs".into(), Value::Object(needs));
948 let scope = Scope {
949 contexts: &contexts,
950 status,
951 hash_files: None,
952 };
953 let condition = job.condition.as_deref().unwrap_or_default();
954 match expr::condition(condition, &scope) {
955 Ok(true) => {}
956 Ok(false) => return self.skip_job(row, None).await,
957 Err(problem) => return self.fail_job(row, &format!("Its `if` does not read: {problem}")).await,
958 }
959 // Its matrix, which may come from a needed job's outputs.
960 let combinations = match &job.matrix {
961 None => vec![Map::new()],
962 Some(matrix) => {
963 let value = match expr::interpolate_value(matrix, &scope) {
964 Ok(value) => value,
965 Err(problem) => return self.fail_job(row, &format!("Its matrix does not read: {problem}")).await,
966 };
967 match matrix::expand(&value) {
968 Ok(combinations) if !combinations.is_empty() => combinations,
969 Ok(_) => return self.fail_job(row, "Its matrix makes no jobs.").await,
970 Err(problem) => return self.fail_job(row, &problem).await,
971 }
972 }
973 };
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9974 // A job that calls a reusable workflow calls it once for each.
975 if let Some(uses) = &job.uses {
976 return self.call_workflow(run, job, row, uses, &contexts, &combinations, caller.as_deref()).await;
977 }
Prices are what g1t pays plus 20%, from the first second978 let total = combinations.len();
979 let raw = job.raw.as_object().cloned().unwrap_or_default();
Fast pages, required checks on the branch, self-hosted runners, honest incidents980 // Whether pull requests from forks may use self-hosted runners here,
981 // asked once, and only for a run that is not trusted.
982 let mut forks_allowed: Option<bool> = None;
Merge branch 'worktree-agent-a3abfcce648e87dca'983 // Each concurrency group its jobs join, and whether it cancels.
984 let mut groups: Vec<(String, bool)> = Vec::new();
Prices are what g1t pays plus 20%, from the first second985 let mut statements = Vec::new();
986 for (index, combination) in combinations.iter().enumerate() {
987 let mut contexts = contexts.clone();
988 contexts.insert("matrix".into(), Value::Object(combination.clone()));
989 contexts.insert(
990 "strategy".into(),
991 json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }),
992 );
993 let scope = Scope {
994 contexts: &contexts,
995 status: Status::Success,
996 hash_files: None,
997 };
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9998 let name = shown_name(job, caller.as_deref(), combination, &scope);
Prices are what g1t pays plus 20%, from the first second999 let runs_on = expr::interpolate_value(&job.runs_on, &scope).unwrap_or(Value::Null);
1000 let labels = match &runs_on {
1001 Value::String(label) => vec![label.clone()],
1002 Value::Array(labels) => labels.iter().map(expr::to_text).collect(),
1003 Value::Object(spec) => spec.get("labels").map(|l| match l {
1004 Value::Array(labels) => labels.iter().map(expr::to_text).collect(),
1005 other => vec![expr::to_text(other)],
1006 }).unwrap_or_default(),
1007 _ => Vec::new(),
1008 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents1009 // `runs-on: self-hosted` (or a group): the workspace's own
1010 // machines, which may run any OS. Otherwise g1t's Linux sandboxes.
1011 let group = match &runs_on {
1012 Value::Object(spec) => spec.get("group").map(expr::to_text),
1013 _ => None,
1014 };
1015 let wanted = Wanted::of(&labels, group.as_deref());
1016 let mut reason = if wanted.self_hosted {
1017 None
1018 } else {
1019 labels
1020 .iter()
1021 .find(|label| {
1022 let lower = label.to_ascii_lowercase();
1023 lower.contains("windows") || lower.contains("macos")
1024 })
1025 .map(|label| {
1026 let os = if label.to_ascii_lowercase().contains("windows") { "windows" } else { "macos" };
1027 format!("`runs-on: {label}`: g1t's own runners are Linux. A self-hosted runner can run it: `runs-on: [self-hosted, {os}]`.")
1028 })
1029 };
1030 // A pull request from a fork runs code anyone could write: never
1031 // on the workspace's machines unless it said they may.
1032 if wanted.self_hosted && run.trusted == 0 {
1033 let allowed = match forks_allowed {
1034 Some(allowed) => allowed,
1035 None => {
1036 let allowed = self.effective_runner_settings(&row.namespace, Some(&run.repo_id)).await?.fork_pull_requests;
1037 forks_allowed = Some(allowed);
1038 allowed
1039 }
1040 };
1041 if !allowed {
1042 reason = Some(
1043 "Pull requests from forks do not run on self-hosted runners here. An admin can allow it under Settings, Runners.".to_owned(),
1044 );
1045 }
1046 }
1047 let max_minutes = if wanted.self_hosted { SELF_HOSTED_MAX_TIMEOUT_MINUTES } else { MAX_TIMEOUT_MINUTES };
Prices are what g1t pays plus 20%, from the first second1048 let timeout = raw
1049 .get("timeout-minutes")
1050 .and_then(|value| expr::interpolate_value(value, &scope).ok())
1051 .and_then(|value| value.as_f64().or_else(|| expr::to_text(&value).parse().ok()))
What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so.1052 .map_or(g1t_actions::workflow::DEFAULT_TIMEOUT_MINUTES.min(max_minutes), |minutes| (minutes.ceil() as u32).clamp(1, max_minutes));
Prices are what g1t pays plus 20%, from the first second1053 let continue_on_error = raw
1054 .get("continue-on-error")
1055 .and_then(|value| expr::interpolate_value(value, &scope).ok())
1056 .is_some_and(|value| expr::truthy(&value));
Merge branch 'worktree-agent-a3abfcce648e87dca'1057 let (mut status, mut conclusion, mut finished) = match &reason {
Prices are what g1t pays plus 20%, from the first second1058 Some(_) => ("completed", Some("failure"), Some(now())),
1059 None => ("queued", None, None),
1060 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents1061 // A self-hosted job waits, saying for what, until a runner takes it.
1062 let (labels_json, queued_at) = if wanted.self_hosted && reason.is_none() {
1063 reason = Some(waiting_reason(&wanted));
1064 (Some(serde_json::to_string(&wanted.stored())?), Some(now()))
1065 } else {
1066 (None, None)
1067 };
Merge branch 'worktree-agent-a3abfcce648e87dca'1068 // The environment it names, an expression read now, and what
1069 // that environment's protection rules say: it starts, waits as
1070 // `pending` (protection.rs), or may not deploy there.
1071 let environment = environment_of(&job.raw, &contexts).map(|env| env.name);
1072 if status == "queued"
1073 && let Some(name) = &environment
1074 {
1075 match self.gate(run, name).await? {
1076 Gate::Open => {}
1077 Gate::Held(why) => {
1078 status = "pending";
1079 reason = Some(why);
1080 }
1081 Gate::Refused(why) => {
1082 (status, conclusion, finished) = ("completed", Some("failure"), Some(now()));
1083 reason = Some(why);
1084 }
1085 }
1086 }
1087 // Its own concurrency group.
1088 let job_group = job
1089 .concurrency
1090 .as_ref()
1091 .and_then(|c| expr::interpolate(&c.group, &scope).ok())
1092 .map(|group| group.trim().to_owned())
1093 .filter(|group| !group.is_empty());
1094 let job_cancel = job
1095 .concurrency
1096 .as_ref()
1097 .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok())
1098 .is_some_and(|value| expr::truthy(&value));
1099 if status != "completed"
1100 && let Some(group) = &job_group
1101 && !groups.iter().any(|(known, _)| known == group)
1102 {
1103 groups.push((group.clone(), job_cancel));
1104 }
Prices are what g1t pays plus 20%, from the first second1105 let values: Vec<worker::wasm_bindgen::JsValue> = vec![
1106 name.into(),
1107 serde_json::to_string(combination)?.into(),
1108 status.into(),
1109 optional(conclusion),
1110 optional(reason.as_deref()),
1111 timeout.into(),
1112 u32::from(continue_on_error).into(),
1113 job.max_parallel.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1114 optional(finished.as_deref()),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1115 optional(labels_json.as_deref()),
1116 optional(queued_at.as_deref()),
Merge branch 'worktree-agent-a3abfcce648e87dca'1117 optional(environment.as_deref()),
1118 optional(job_group.as_deref()),
1119 u32::from(job_cancel).into(),
Prices are what g1t pays plus 20%, from the first second1120 ];
1121 if index == 0 {
1122 let mut bound = values;
1123 bound.push(row.id.as_str().into());
1124 statements.push(
1125 self.db
1126 .prepare(
1127 "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, timeout_minutes = ?,
Merge branch 'worktree-agent-a3abfcce648e87dca'1128 continue_on_error = ?, max_parallel = ?, finished_at = ?, labels = ?, queued_at = ?, environment = ?,
1129 concurrency_group = ?, cancel_in_progress = ? WHERE id = ?",
Prices are what g1t pays plus 20%, from the first second1130 )
1131 .bind(&bound)?,
1132 );
1133 } else {
1134 let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![
1135 new_id("job", now_ms()).into(),
1136 row.run_id.as_str().into(),
1137 row.repo_id.as_str().into(),
1138 row.namespace.as_str().into(),
1139 row.key.as_str().into(),
1140 (index as u32).into(),
1141 row.needs.as_str().into(),
1142 optional(row.call.as_deref()),
1143 ];
1144 bound.extend(values);
1145 statements.push(
1146 self.db
1147 .prepare(
1148 "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, call, name, matrix, status, conclusion, reason,
Merge branch 'worktree-agent-a3abfcce648e87dca'1149 timeout_minutes, continue_on_error, max_parallel, finished_at, labels, queued_at, environment,
1150 concurrency_group, cancel_in_progress)
1151 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Prices are what g1t pays plus 20%, from the first second1152 )
1153 .bind(&bound)?,
1154 );
1155 }
1156 }
1157 self.db.batch(statements).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1158 self.replace_in_groups(run, &row.key, &groups).await
1159 }
1160
1161 /// A job queued in its own concurrency group: with `cancel-in-progress`
1162 /// it cancels the group's other jobs; otherwise it replaces any that
1163 /// are queued and not started, and waits for the one running
1164 /// (`start_queued` starts one of a group at a time). As on GitHub.
1165 async fn replace_in_groups(&self, run: &RunRow, key: &str, groups: &[(String, bool)]) -> Result<()> {
1166 if groups.is_empty() {
1167 return Ok(());
1168 }
1169 #[derive(Deserialize)]
1170 struct Id {
1171 id: String,
1172 }
1173 let mine: Vec<String> = self
1174 .db
1175 .prepare("SELECT id FROM jobs WHERE run_id = ? AND key = ?")
1176 .bind(&[run.id.as_str().into(), key.into()])?
1177 .all()
1178 .await?
1179 .results::<Id>()?
1180 .into_iter()
1181 .map(|row| row.id)
1182 .collect();
1183 let mut moved: Vec<String> = Vec::new();
1184 for (group, cancel) in groups {
1185 let others = self
1186 .db
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91187 // A call that waits in the group, or calls, is in it too.
1188 .prepare("SELECT * FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('waiting', 'queued', 'pending', 'in_progress', 'calling')")
Merge branch 'worktree-agent-a3abfcce648e87dca'1189 .bind(&[run.repo_id.as_str().into(), group.as_str().into()])?
1190 .all()
1191 .await?
1192 .results::<JobRow>()?;
1193 for other in others.iter().filter(|other| !mine.contains(&other.id)) {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91194 if *cancel || matches!(other.status.as_str(), "queued" | "waiting") {
Merge branch 'worktree-agent-a3abfcce648e87dca'1195 self.stop_job(other, "A newer job in the same concurrency group replaced it.").await?;
1196 if !moved.contains(&other.run_id) {
1197 moved.push(other.run_id.clone());
1198 }
1199 }
1200 }
1201 }
1202 for other_run in moved.iter().filter(|id| **id != run.id) {
1203 Box::pin(self.advance(other_run)).await?;
1204 }
Prices are what g1t pays plus 20%, from the first second1205 Ok(())
1206 }
1207
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1208 /// A job that calls a reusable workflow, in the repository or another
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91209 /// (reach.rs): once for each combination of its matrix, as on GitHub,
1210 /// each with the inputs and secrets it passes read with that
1211 /// combination's `matrix`. Each call waits (`holds_call`) until its
1212 /// matrix's `max-parallel` and its concurrency group have room, when
1213 /// `start_call` puts the workflow's jobs in the run under it.
1214 #[allow(clippy::too_many_arguments)]
1215 async fn call_workflow(
1216 &self,
1217 run: &RunRow,
1218 job: &workflow::Job,
1219 row: &JobRow,
1220 uses: &str,
1221 contexts: &Map<String, Value>,
1222 combinations: &[Map<String, Value>],
1223 caller: Option<&str>,
1224 ) -> Result<()> {
Prices are what g1t pays plus 20%, from the first second1225 let depth = row.call().and_then(|c| c["depth"].as_u64()).unwrap_or(0) + 1;
1226 if depth > MAX_CALL_DEPTH {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91227 return self.fail_job(row, &call_allowed(depth, &[], uses).unwrap_err()).await;
Prices are what g1t pays plus 20%, from the first second1228 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1229 let (file, source, origin) = match self.called_workflow(run, row, uses).await? {
1230 Ok(found) => found,
1231 Err(why) => return self.fail_job(row, &why).await,
Prices are what g1t pays plus 20%, from the first second1232 };
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91233 let jobs = self.job_rows(&run.id).await?;
1234 // Each call so far, by the workflow it called.
1235 let called_paths: Vec<String> = jobs
1236 .iter()
1237 .filter(|other| other.call_prefix().is_some())
1238 .filter_map(JobRow::call)
1239 .filter_map(|call| call["path"].as_str().map(str::to_owned))
1240 .collect();
1241 let called_already: Vec<&str> = called_paths.iter().map(String::as_str).collect();
1242 if let Err(why) = call_allowed(depth, &called_already, &file) {
1243 return self.fail_job(row, &why).await;
1244 }
Prices are what g1t pays plus 20%, from the first second1245 let called = match workflow::parse(&source) {
1246 Ok(called) => called,
1247 Err(problem) => return self.fail_job(row, &format!("`{file}` does not read: {problem}")).await,
1248 };
1249 let Some(trigger) = called.trigger("workflow_call") else {
1250 return self.fail_job(row, &format!("`{file}` cannot be called: it has no `on: workflow_call`.")).await;
1251 };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1252 // Secrets: none but the job's token unless `secrets:` passes them,
1253 // by name or with `inherit`; read when each job starts (job_spec).
1254 let outer = row.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned());
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91255 let total = combinations.len();
1256 let mut groups: Vec<(String, bool)> = Vec::new();
1257 let mut statements = Vec::new();
1258 for (index, combination) in combinations.iter().enumerate() {
1259 let mut contexts = contexts.clone();
1260 contexts.insert("matrix".into(), Value::Object(combination.clone()));
1261 contexts.insert(
1262 "strategy".into(),
1263 json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }),
1264 );
1265 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
1266 let name = shown_name(job, caller, combination, &scope);
1267 // Inputs: what `with:` passes, as the called workflow types them.
1268 let given = match job.raw.get("with") {
1269 Some(with) => match expr::interpolate_value(with, &scope) {
1270 Ok(Value::Object(given)) => Ok(given),
1271 Ok(_) => Ok(Map::new()),
1272 Err(problem) => Err(format!("Its `with` does not read: {problem}")),
1273 },
1274 None => Ok(Map::new()),
1275 };
1276 let inputs = given.and_then(|given| crate::reach::call_inputs(&file, &trigger.inputs, &given));
1277 let secrets = crate::reach::secrets_plan(&job.raw, &contexts, outer.clone());
1278 let problem = match &inputs {
1279 Err(why) => Some(why.clone()),
1280 Ok(_) => crate::reach::missing_secrets(&called.raw, &secrets)
1281 .first()
1282 .map(|name| format!("`{file}` needs the secret `{name}`: pass it under `secrets:`, or use `secrets: inherit`."))
1283 .or_else(|| {
1284 crate::reach::undeclared_secrets(&called.raw, &secrets)
1285 .first()
1286 .map(|name| format!("Invalid secret: `{name}` is not defined in `{file}`."))
1287 }),
1288 };
1289 // Its own concurrency group, which it holds while its workflow runs.
1290 let group = job
1291 .concurrency
1292 .as_ref()
1293 .and_then(|c| expr::interpolate(&c.group, &scope).ok())
1294 .map(|group| group.trim().to_owned())
1295 .filter(|group| !group.is_empty());
1296 let cancel = job
1297 .concurrency
1298 .as_ref()
1299 .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok())
1300 .is_some_and(|value| expr::truthy(&value));
1301 if problem.is_none()
1302 && let Some(group) = &group
1303 && !groups.iter().any(|(known, _)| known == group)
1304 {
1305 groups.push((group.clone(), cancel));
1306 }
1307 let call = json!({
1308 "role": "caller", "path": file, "source": source, "origin": origin, "depth": depth,
1309 "prefix": call_prefix(&row.key, job.matrix.is_some().then_some(index)), "title": name,
1310 "inputs": inputs.unwrap_or_default(), "secrets": secrets, "fail_fast": job.fail_fast,
1311 });
1312 let (status, conclusion, finished) = match &problem {
1313 Some(_) => ("completed", Some("failure"), Some(now())),
1314 None => ("waiting", None, None),
1315 };
1316 let values: Vec<worker::wasm_bindgen::JsValue> = vec![
1317 name.as_str().into(),
1318 serde_json::to_string(combination)?.into(),
1319 status.into(),
1320 optional(conclusion),
1321 optional(problem.as_deref()),
1322 job.max_parallel.map(|max| max.max(1)).map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1323 optional(finished.as_deref()),
1324 optional(group.as_deref()),
1325 u32::from(cancel).into(),
1326 serde_json::to_string(&call)?.into(),
1327 ];
1328 if index == 0 {
1329 let mut bound = values;
1330 bound.push(row.id.as_str().into());
1331 statements.push(
1332 self.db
1333 .prepare(
1334 "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, max_parallel = ?, finished_at = ?,
1335 concurrency_group = ?, cancel_in_progress = ?, call = ? WHERE id = ?",
1336 )
1337 .bind(&bound)?,
1338 );
1339 } else {
1340 let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![
1341 new_id("job", now_ms()).into(),
1342 row.run_id.as_str().into(),
1343 row.repo_id.as_str().into(),
1344 row.namespace.as_str().into(),
1345 row.key.as_str().into(),
1346 (index as u32).into(),
1347 row.needs.as_str().into(),
1348 ];
1349 bound.extend(values);
1350 statements.push(
1351 self.db
1352 .prepare(
1353 "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, name, matrix, status, conclusion, reason,
1354 max_parallel, finished_at, concurrency_group, cancel_in_progress, call)
1355 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1356 )
1357 .bind(&bound)?,
1358 );
1359 }
1360 }
1361 self.db.batch(statements).await?;
1362 self.replace_in_groups(run, &row.key, &groups).await
1363 }
1364
1365 /// Whether another job of `row`'s concurrency group runs, or calls a
1366 /// workflow, so that `row` waits for it.
1367 async fn group_busy(&self, row: &JobRow) -> Result<bool> {
1368 let Some(group) = &row.concurrency_group else { return Ok(false) };
1369 let busy = self
1370 .db
1371 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?")
1372 .bind(&[row.repo_id.as_str().into(), group.as_str().into(), row.id.as_str().into()])?
1373 .first::<Count>(None)
1374 .await?
1375 .map_or(0, |count| count.n);
1376 Ok(busy > 0)
1377 }
1378
1379 /// A call that waited (`holds_call`) is made: the called workflow's
1380 /// jobs join the run under it, named under its name and keyed under its
1381 /// prefix. False when it no longer waits.
1382 async fn start_call(&self, row: &JobRow) -> Result<bool> {
1383 let Some(call) = row.call() else { return Ok(false) };
1384 let Some(prefix) = row.call_prefix() else { return Ok(false) };
1385 let Some(called) = call["source"].as_str().and_then(|source| workflow::parse(source).ok()) else { return Ok(false) };
1386 let file = call["path"].as_str().unwrap_or_default();
1387 let started = self
1388 .db
1389 .prepare("UPDATE jobs SET status = 'calling', reason = ?, started_at = ? WHERE id = ? AND status = 'waiting' RETURNING id")
1390 .bind(&[format!("Calls `{file}`.").into(), now().into(), row.id.as_str().into()])?
1391 .first::<Value>(None)
1392 .await?;
1393 if started.is_none() {
1394 return Ok(false);
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1395 }
Prices are what g1t pays plus 20%, from the first second1396 let mut statements = Vec::new();
1397 for called_job in &called.jobs {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91398 let needs: Vec<String> = called_job.needs.iter().map(|n| format!("{prefix}/{n}")).collect();
1399 let callee = json!({
1400 "role": "callee", "parent": prefix, "title": row.name, "job": called_job.id, "path": file,
1401 "source": call["source"], "inputs": call["inputs"], "depth": call["depth"], "origin": call["origin"], "secrets": call["secrets"],
Prices are what g1t pays plus 20%, from the first second1402 });
1403 statements.push(
1404 self.db
1405 .prepare("INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status, call) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?)")
1406 .bind(&[
1407 new_id("job", now_ms()).into(),
1408 row.run_id.as_str().into(),
1409 row.repo_id.as_str().into(),
1410 row.namespace.as_str().into(),
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91411 format!("{prefix}/{}", called_job.id).into(),
Prices are what g1t pays plus 20%, from the first second1412 format!("{} / {}", row.name, called_job.name.clone().unwrap_or(called_job.id.clone())).into(),
1413 serde_json::to_string(&needs)?.into(),
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91414 serde_json::to_string(&callee)?.into(),
Prices are what g1t pays plus 20%, from the first second1415 ])?,
1416 );
1417 }
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91418 if !statements.is_empty() {
1419 self.db.batch(statements).await?;
1420 }
1421 Ok(true)
1422 }
1423
1424 /// The runs with a call waiting in `group`, but `run_id`, moved along
1425 /// now that a job of the group has finished.
1426 async fn wake_group(&self, repo_id: &str, group: &str, run_id: &str) -> Result<()> {
1427 #[derive(Deserialize)]
1428 struct RunId {
1429 run_id: String,
1430 }
1431 let waiting = self
1432 .db
1433 .prepare("SELECT DISTINCT run_id FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status = 'waiting' AND run_id != ?")
1434 .bind(&[repo_id.into(), group.into(), run_id.into()])?
1435 .all()
1436 .await?
1437 .results::<RunId>()?;
1438 for other in waiting {
1439 Box::pin(self.advance(&other.run_id)).await?;
1440 }
Prices are what g1t pays plus 20%, from the first second1441 Ok(())
1442 }
1443
1444 /// A job that called a workflow, finished with its jobs: their result,
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91445 /// and the outputs the workflow declares. A failed call of a fail-fast
1446 /// matrix stops the matrix's other calls.
1447 async fn finish_call(&self, run: &RunRow, row: &JobRow, children: &[&JobRow]) -> Result<()> {
Prices are what g1t pays plus 20%, from the first second1448 let call = row.call().unwrap_or_default();
1449 let called = call["source"].as_str().and_then(|s| workflow::parse(s).ok());
1450 let mut jobs_context = Map::new();
1451 let mut by_key: std::collections::BTreeMap<String, Vec<&JobRow>> = std::collections::BTreeMap::new();
1452 for child in children {
1453 by_key.entry(child.key.clone()).or_default().push(child);
1454 }
1455 for (key, rows) in &by_key {
1456 let mut outputs = Map::new();
1457 for child in rows {
1458 if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&child.outputs) {
1459 outputs.extend(more);
1460 }
1461 }
1462 let id = key.rsplit('/').next().unwrap_or(key);
1463 jobs_context.insert(id.to_owned(), json!({ "result": key_result(rows), "outputs": outputs }));
1464 }
1465 let inputs = children.first().and_then(|c| c.call()).map(|c| c["inputs"].clone()).unwrap_or(json!({}));
1466 let mut contexts = Map::new();
1467 contexts.insert("jobs".into(), Value::Object(jobs_context));
1468 contexts.insert("inputs".into(), inputs);
1469 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
1470 let mut outputs = Map::new();
1471 if let Some(Value::Object(declared)) = called.as_ref().map(|w| {
1472 let on = w.raw.get("on").or_else(|| w.raw.get("true")).cloned().unwrap_or(Value::Null);
1473 on.get("workflow_call").and_then(|c| c.get("outputs")).cloned().unwrap_or(Value::Null)
1474 }) {
1475 for (name, spec) in declared {
1476 if let Some(value) = spec.get("value") {
1477 let value = expr::interpolate_value(value, &scope).unwrap_or(Value::Null);
1478 outputs.insert(name, Value::String(expr::to_text(&value)));
1479 }
1480 }
1481 }
1482 let conclusion = key_result(children);
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91483 let finished = self
1484 .db
1485 .prepare("UPDATE jobs SET status = 'completed', conclusion = ?, outputs = ?, finished_at = ? WHERE id = ? AND status = 'calling' RETURNING id")
Prices are what g1t pays plus 20%, from the first second1486 .bind(&[conclusion.into(), serde_json::to_string(&outputs)?.into(), now().into(), row.id.as_str().into()])?
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91487 .first::<Value>(None)
Prices are what g1t pays plus 20%, from the first second1488 .await?;
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91489 if finished.is_none() {
1490 return Ok(());
1491 }
1492 if conclusion == "failure" && row.matrix.as_deref().is_some_and(|m| m != "{}") && call["fail_fast"] != json!(false) {
1493 let siblings = self
1494 .db
1495 .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'")
1496 .bind(&[row.run_id.as_str().into(), row.key.as_str().into()])?
1497 .all()
1498 .await?
1499 .results::<JobRow>()?;
1500 for sibling in siblings {
1501 self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?;
1502 }
1503 }
1504 if let Some(group) = &row.concurrency_group {
1505 self.wake_group(&run.repo_id, group, &run.id).await?;
1506 }
Prices are what g1t pays plus 20%, from the first second1507 Ok(())
1508 }
1509
1510 /// A file's text at a commit, if it is there.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1511 pub(crate) async fn read_file(&self, path: &RepoPath, ws: &g1t_contracts::User, sha: &str, file: &str) -> Result<Option<String>> {
Prices are what g1t pays plus 20%, from the first second1512 let blob: Outcome<g1t_contracts::repos::BlobView> = g1t_kit::call(
1513 &self.repos,
1514 "blob",
1515 &g1t_contracts::repos::BlobArgs {
1516 path: path.clone(),
1517 viewer: Some(ws.clone()),
1518 git_ref: sha.to_owned(),
1519 file_path: file.to_owned(),
1520 },
1521 )
1522 .await?;
1523 Ok(match blob {
1524 Outcome::Ok(view) => view.text,
1525 Outcome::Fail(_) => None,
1526 })
1527 }
1528
1529 async fn skip_job(&self, row: &JobRow, reason: Option<&str>) -> Result<()> {
1530 self.db
1531 .prepare("UPDATE jobs SET status = 'completed', conclusion = 'skipped', reason = ?, finished_at = ? WHERE id = ?")
1532 .bind(&[optional(reason), now().into(), row.id.as_str().into()])?
1533 .run()
1534 .await?;
1535 Ok(())
1536 }
1537
1538 async fn fail_job(&self, row: &JobRow, reason: &str) -> Result<()> {
1539 self.db
1540 .prepare("UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ? WHERE id = ? AND status != 'completed'")
1541 .bind(&[reason.into(), now().into(), row.id.as_str().into()])?
1542 .run()
1543 .await?;
1544 Ok(())
1545 }
1546
1547 /// Starts queued jobs, oldest first, while their workspace has room.
1548 pub async fn start_queued(&self) -> Result<()> {
1549 let queued = self
1550 .db
Fast pages, required checks on the branch, self-hosted runners, honest incidents1551 // Self-hosted jobs are taken by their runners (runners.rs).
1552 .prepare("SELECT * FROM jobs WHERE status = 'queued' AND labels IS NULL ORDER BY rowid LIMIT 50")
Prices are what g1t pays plus 20%, from the first second1553 .all()
1554 .await?
1555 .results::<JobRow>()?;
1556 let mut running: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
1557 for job in queued {
1558 let in_workspace = match running.get(&job.namespace) {
1559 Some(n) => *n,
1560 None => {
1561 let n = self
1562 .db
Fast pages, required checks on the branch, self-hosted runners, honest incidents1563 // Only g1t's own sandboxes count against the workspace's room.
1564 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE status = 'in_progress' AND namespace = ? AND runner_id IS NULL")
Prices are what g1t pays plus 20%, from the first second1565 .bind(&[job.namespace.as_str().into()])?
1566 .first::<Count>(None)
1567 .await?
1568 .map_or(0, |count| count.n);
1569 running.insert(job.namespace.clone(), n);
1570 n
1571 }
1572 };
1573 if in_workspace >= RUNNING_PER_WORKSPACE {
1574 continue;
1575 }
1576 if let Some(max) = job.max_parallel {
1577 let siblings = self
1578 .db
1579 .prepare("SELECT COUNT(*) AS n FROM jobs WHERE run_id = ? AND key = ? AND status = 'in_progress'")
1580 .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])?
1581 .first::<Count>(None)
1582 .await?
1583 .map_or(0, |count| count.n);
1584 if siblings >= max {
1585 continue;
1586 }
1587 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1588 // One job of a concurrency group runs at a time.
1589 if let Some(group) = &job.concurrency_group {
1590 let running = self
1591 .db
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91592 .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'1593 .bind(&[job.repo_id.as_str().into(), group.as_str().into(), job.id.as_str().into()])?
1594 .first::<Count>(None)
1595 .await?
1596 .map_or(0, |count| count.n);
1597 if running > 0 {
1598 continue;
1599 }
1600 }
Prices are what g1t pays plus 20%, from the first second1601 let token = random_hex(24);
1602 let at = now();
1603 let claimed = self
1604 .db
1605 .prepare(
1606 "UPDATE jobs SET status = 'in_progress', token_hash = ?, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id",
1607 )
1608 .bind(&[sha256_hex(&token).into(), at.as_str().into(), at.as_str().into(), job.id.as_str().into()])?
1609 .first::<Value>(None)
1610 .await?;
1611 if claimed.is_none() {
1612 continue;
1613 }
1614 running.insert(job.namespace.clone(), in_workspace + 1);
1615 self.db
1616 .prepare("UPDATE runs SET status = 'in_progress', started_at = COALESCE(started_at, ?) WHERE id = ? AND status = 'queued'")
1617 .bind(&[at.as_str().into(), job.run_id.as_str().into()])?
1618 .run()
1619 .await?;
1620 let run = self.run_row(&job.run_id).await?;
1621 let repo: RepoPath = run.as_ref().map(|run| repo_path(&run.repo)).unwrap_or(RepoPath {
1622 namespace: job.namespace.clone(),
1623 name: String::new(),
1624 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents1625 // Its environment and the machine it asked for, for the runner.
1626 let details = run.as_ref().map(|run| start_details(run, &job)).unwrap_or_default();
Prices are what g1t pays plus 20%, from the first second1627 let started: Outcome<Value> = g1t_kit::call(
1628 &self.runner,
1629 "start_actions_job",
1630 &StartJobArgs {
1631 job: job.id.clone(),
1632 token,
1633 repo,
1634 timeout_minutes: job.timeout_minutes,
Fast pages, required checks on the branch, self-hosted runners, honest incidents1635 workflow: run.as_ref().map(|run| run.path.clone()),
1636 environment: details.environment,
1637 trusted: run.as_ref().is_some_and(|run| run.trusted != 0),
1638 instance: details.instance,
Prices are what g1t pays plus 20%, from the first second1639 },
1640 )
1641 .await
1642 .unwrap_or_else(|error| fail(FailureCode::Conflict, format!("The runner could not be reached: {error}")));
1643 if let Outcome::Fail(refused) = started {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1644 // Billing would not pay for a scheduled run's job: the
1645 // schedule waits rather than making runs only to refuse them.
1646 if refused.code == FailureCode::PaymentRequired
1647 && let Some(run) = run.as_ref().filter(|run| run.event == "schedule")
1648 {
1649 self.db
1650 .prepare("UPDATE workflows SET schedule_refused_until = ? WHERE id = ?")
1651 .bind(&[rfc3339(now_ms() + crate::SCHEDULE_REFUSED_MS).into(), run.workflow_id.as_str().into()])?
1652 .run()
1653 .await?;
1654 }
Prices are what g1t pays plus 20%, from the first second1655 Box::pin(self.finish_job(&job.id, "failure", Some(&refused.message), None)).await?;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971656 } else {
1657 self.job_started(&job.id).await?;
Prices are what g1t pays plus 20%, from the first second1658 }
1659 }
1660 Ok(())
1661 }
1662
1663 /// Finishes a job and moves its run along.
1664 pub async fn finish_job(&self, job_id: &str, conclusion: &str, reason: Option<&str>, outputs: Option<&Map<String, Value>>) -> Result<()> {
1665 let finished = self
1666 .db
1667 .prepare(
1668 "UPDATE jobs SET status = 'completed', conclusion = ?, reason = COALESCE(?, reason), outputs = COALESCE(?, outputs),
1669 finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *",
1670 )
1671 .bind(&[
1672 conclusion.into(),
1673 optional(reason),
1674 outputs.map(|o| serde_json::to_string(o).unwrap_or_default()).as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1675 now().into(),
1676 job_id.into(),
1677 ])?
1678 .first::<JobRow>(None)
1679 .await?;
1680 let Some(job) = finished else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'1681 // Its G1T_TOKEN stops working with it.
1682 self.revoke_job_tokens(&job.id).await;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971683 // A job that deploys failed: so did its run's deployment, now.
1684 if conclusion == "failure" && job.continue_on_error == 0 && job.started_at.is_some()
1685 && let Some(run) = self.run_row(&job.run_id).await?
1686 && let Some(env) = deploys_to(&run, &job)
1687 {
1688 self.report_deployment(&run, &env, "failure", false).await;
1689 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents1690 // A self-hosted runner's job: the runner is free again, and its time
1691 // is recorded, at nothing.
1692 if job.runner_id.is_some() {
1693 self.released(&job).await?;
1694 }
Prices are what g1t pays plus 20%, from the first second1695 // Steps still marked as going are not going any more.
1696 let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
1697 let mut touched = false;
1698 for step in steps.iter_mut() {
1699 if step["status"] != "completed" {
1700 let was_running = step["status"] == "in_progress";
1701 step["status"] = json!("completed");
1702 step["conclusion"] = json!(if was_running { conclusion } else { "skipped" });
1703 touched = true;
1704 }
1705 }
1706 if touched {
1707 self.db
1708 .prepare("UPDATE jobs SET steps = ? WHERE id = ?")
1709 .bind(&[serde_json::to_string(&steps)?.into(), job.id.as_str().into()])?
1710 .run()
1711 .await?;
1712 }
1713 // fail-fast: one failed combination stops the rest of its matrix.
1714 if conclusion == "failure" && job.continue_on_error == 0 && job.matrix.as_deref().is_some_and(|m| m != "{}") {
1715 let run = self.run_row(&job.run_id).await?;
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91716 // As its workflow, or the workflow it is a job of, says.
1717 let fail_fast = run.as_ref().and_then(|run| job_spec(run, &job)).is_none_or(|j| j.fail_fast);
Prices are what g1t pays plus 20%, from the first second1718 if fail_fast {
1719 let siblings = self
1720 .db
1721 .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'")
1722 .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])?
1723 .all()
1724 .await?
1725 .results::<JobRow>()?;
1726 for sibling in siblings {
1727 self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?;
1728 }
1729 }
1730 }
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91731 // A call waiting in its concurrency group, in another run, may start.
1732 if let Some(group) = &job.concurrency_group {
1733 self.wake_group(&job.repo_id, group, &job.run_id).await?;
1734 }
Prices are what g1t pays plus 20%, from the first second1735 Box::pin(self.advance(&job.run_id)).await
1736 }
1737
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1738 /// Cancels a job. One that is running is told to stop (the answer to
1739 /// its next report), and runs its `if: always()` and `cancelled()`
1740 /// steps and its post steps before it ends `cancelled`; one that has
1741 /// not started is cancelled at once.
Prices are what g1t pays plus 20%, from the first second1742 async fn stop_job(&self, job: &JobRow, reason: &str) -> Result<()> {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r91743 // A job calling a workflow stops with that workflow's jobs.
1744 if job.status == "calling"
1745 && let Some(prefix) = job.call_prefix()
1746 {
1747 let under = format!("{prefix}/");
1748 for child in self.job_rows(&job.run_id).await?.iter().filter(|child| child.key.starts_with(&under) && child.status != "completed") {
1749 Box::pin(self.stop_job(child, reason)).await?;
1750 }
1751 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1752 if job.status == "in_progress" && job.started_at.is_some() {
1753 self.db
1754 .prepare("UPDATE jobs SET cancel_requested_at = COALESCE(cancel_requested_at, ?), reason = ? WHERE id = ? AND status = 'in_progress'")
1755 .bind(&[now().into(), reason.into(), job.id.as_str().into()])?
1756 .run()
1757 .await?;
1758 return Ok(());
1759 }
1760 self.hard_stop(job, reason).await
1761 }
1762
1763 /// Cancels a job outright, stopping its sandbox if it has one.
1764 async fn hard_stop(&self, job: &JobRow, reason: &str) -> Result<()> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1765 // A self-hosted runner hears it was cancelled on its next poll.
1766 if job.status == "in_progress" && job.runner_id.is_none() {
Prices are what g1t pays plus 20%, from the first second1767 let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await;
1768 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents1769 let stopped = self
1770 .db
1771 .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *")
Prices are what g1t pays plus 20%, from the first second1772 .bind(&[reason.into(), now().into(), job.id.as_str().into()])?
Fast pages, required checks on the branch, self-hosted runners, honest incidents1773 .first::<JobRow>(None)
Prices are what g1t pays plus 20%, from the first second1774 .await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1775 if stopped.as_ref().is_some_and(|row| row.started_at.is_some()) {
1776 self.revoke_job_tokens(&job.id).await;
1777 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents1778 if let Some(stopped) = stopped.filter(|row| row.runner_id.is_some()) {
1779 self.released(&stopped).await?;
1780 }
Prices are what g1t pays plus 20%, from the first second1781 Ok(())
1782 }
1783
Merge branch 'worktree-agent-a3abfcce648e87dca'1784 /// Ends a job's tokens at once. A failure is logged: the token expires
1785 /// on its own soon after the job's time limit.
1786 async fn revoke_job_tokens(&self, job_id: &str) {
1787 let revoked: Result<bool> =
1788 g1t_kit::call(&self.identity, "revoke_job_tokens", &RevokeJobTokensArgs { job_id: job_id.to_owned() }).await;
1789 if let Err(error) = revoked {
1790 worker::console_error!("actions: the tokens of job {job_id} were not revoked: {error}");
1791 }
1792 }
1793
Prices are what g1t pays plus 20%, from the first second1794 /// Finishes the run when every job has.
1795 async fn finish_if_done(&self, run_id: &str) -> Result<()> {
1796 let Some(run) = self.run_row(run_id).await? else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'1797 if matches!(run.status.as_str(), "completed" | "pending" | "action_required") {
Prices are what g1t pays plus 20%, from the first second1798 return Ok(());
1799 }
1800 let jobs = self.job_rows(run_id).await?;
1801 if !jobs.iter().all(|job| job.status == "completed") {
1802 return Ok(());
1803 }
1804 self.finish_run(&run, None).await
1805 }
1806
1807 async fn finish_run(&self, run: &RunRow, error: Option<&str>) -> Result<()> {
1808 let jobs = self.job_rows(&run.id).await?;
1809 let rows: Vec<&JobRow> = jobs.iter().collect();
1810 let conclusion = if error.is_some() {
1811 "failure"
1812 } else if run.conclusion.as_deref() == Some("cancelled") {
1813 "cancelled"
1814 } else if rows.is_empty() {
1815 "skipped"
1816 } else {
1817 key_result(&rows)
1818 };
1819 let done = self
1820 .db
1821 .prepare("UPDATE runs SET status = 'completed', conclusion = ?, error = COALESCE(?, error), finished_at = ? WHERE id = ? AND status != 'completed' RETURNING id")
1822 .bind(&[conclusion.into(), optional(error), now().into(), run.id.as_str().into()])?
1823 .first::<Value>(None)
1824 .await?;
1825 if done.is_none() {
1826 return Ok(());
1827 }
1828 self.report_status(run, conclusion).await?;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971829 self.settle_deployments(run, &jobs, conclusion == "cancelled").await;
Prices are what g1t pays plus 20%, from the first second1830 let published: Result<()> = g1t_kit::call(
1831 &self.events,
1832 "publish",
1833 &g1t_contracts::events::Publish {
1834 events: vec![g1t_contracts::events::NewEvent {
1835 kind: "workflow.completed",
1836 source: "actions",
1837 repo_id: Some(run.repo_id.clone()),
1838 actor: run.actor_id.clone(),
1839 data: g1t_contracts::events::WorkflowEvent {
1840 run_id: run.id.clone(),
1841 repo_id: run.repo_id.clone(),
1842 workflow: run.name.clone(),
1843 path: run.path.clone(),
1844 number: run.number,
1845 event: run.event.clone(),
1846 conclusion: conclusion.to_owned(),
1847 git_ref: run.git_ref.clone(),
1848 sha: run.sha.clone(),
1849 pull: run.pull,
1850 },
1851 }],
1852 },
1853 )
1854 .await;
1855 if let Err(error) = published {
1856 worker::console_error!("actions: could not publish workflow.completed: {error}");
1857 }
1858 // The next run waiting in its concurrency group.
1859 if let Some(group) = &run.concurrency_group {
1860 let next = self
1861 .db
1862 .prepare("SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND status = 'pending' ORDER BY id LIMIT 1")
1863 .bind(&[run.repo_id.as_str().into(), group.as_str().into()])?
1864 .first::<RunRow>(None)
1865 .await?;
1866 if let Some(next) = next {
1867 self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[next.id.as_str().into()])?.run().await?;
1868 Box::pin(self.advance(&next.id)).await?;
1869 }
1870 }
1871 Ok(())
1872 }
1873
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971874 /// Tells the deployments service how a run's deployment to `env` stands:
1875 /// made when its first job naming the environment starts, failed when one
1876 /// of them fails, and settled (`last`) when the run finishes. Never fails
1877 /// the run: a deployment that could not be recorded is logged.
1878 async fn report_deployment(&self, run: &RunRow, env: &JobEnvironment, state: &str, last: bool) {
1879 let path = repo_path(&run.repo);
1880 let reported: Result<Value> = g1t_kit::call(
1881 &self.deployments,
1882 "actions_deployment",
1883 &json!({
1884 "repoId": run.repo_id,
1885 "repo": { "namespace": path.namespace, "name": path.name },
1886 "runId": run.id,
1887 "attempt": run.attempt,
1888 "runUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
1889 "environment": env.name,
1890 "url": env.url,
1891 "ref": run.git_ref,
1892 "sha": run.sha,
1893 "state": state,
1894 "final": last,
1895 "creator": run.actor,
1896 "workflow": run.name,
1897 }),
1898 )
1899 .await;
1900 if let Err(error) = reported {
1901 worker::console_error!("actions: deployment to {} not recorded for run {}: {error}", env.name, run.id);
1902 }
1903 }
1904
1905 /// A job that deploys has started: its run's deployment to the
1906 /// environment is under way.
1907 pub(crate) async fn job_started(&self, job_id: &str) -> Result<()> {
1908 let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job_id.into()])?.first::<JobRow>(None).await? else {
1909 return Ok(());
1910 };
1911 let Some(run) = self.run_row(&job.run_id).await? else { return Ok(()) };
1912 if let Some(env) = deploys_to(&run, &job) {
1913 self.report_deployment(&run, &env, "in_progress", false).await;
1914 }
1915 Ok(())
1916 }
1917
1918 /// The run is over: each environment its jobs deployed to takes the
1919 /// outcome of those jobs (`deployment_outcome`).
1920 async fn settle_deployments(&self, run: &RunRow, jobs: &[JobRow], cancelled: bool) {
1921 let mut seen: Vec<(JobEnvironment, Vec<Option<String>>)> = Vec::new();
1922 for job in jobs {
1923 let Some(env) = deploys_to(run, job) else { continue };
1924 let conclusion = if cancelled && job.conclusion.as_deref() != Some("skipped") && job.started_at.is_some() {
1925 Some("cancelled".to_owned())
1926 } else if job.started_at.is_none() {
1927 Some("skipped".to_owned())
1928 } else {
1929 job.conclusion.clone()
1930 };
1931 match seen.iter_mut().find(|(known, _)| known.name.eq_ignore_ascii_case(&env.name)) {
1932 Some((known, conclusions)) => {
1933 if known.url.is_none() {
1934 known.url = env.url.clone();
1935 }
1936 conclusions.push(conclusion);
1937 }
1938 None => seen.push((env, vec![conclusion])),
1939 }
1940 }
1941 for (env, conclusions) in seen {
1942 if let Some(state) = deployment_outcome(&conclusions) {
1943 self.report_deployment(run, &env, state, true).await;
1944 }
1945 }
1946 }
1947
Prices are what g1t pays plus 20%, from the first second1948 /// Tells the pull request (or commit) how the run went, as a status.
1949 async fn report_status(&self, run: &RunRow, conclusion: &str) -> Result<()> {
1950 let state = match conclusion {
1951 "success" | "skipped" => "success",
1952 "cancelled" => "error",
1953 _ => "failure",
1954 };
1955 let _: Result<Value> = g1t_kit::call(
1956 &self.work,
1957 "set_commit_status",
1958 &json!({
1959 "repoId": run.repo_id,
1960 "sha": run.sha,
1961 "context": format!("{} / {}", run.name, run.event),
1962 "state": state,
1963 "description": format!("{} {}", run.name, match conclusion {
1964 "success" => "passed",
1965 "skipped" => "was skipped",
1966 "cancelled" => "was cancelled",
1967 _ => "failed",
1968 }),
1969 "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1970 "source": "actions",
Prices are what g1t pays plus 20%, from the first second1971 }),
1972 )
1973 .await;
1974 Ok(())
1975 }
1976
1977 /// Tells the pull request a run has started on its head.
1978 pub async fn report_pending(&self, run: &RunRow) -> Result<()> {
1979 let _: Result<Value> = g1t_kit::call(
1980 &self.work,
1981 "set_commit_status",
1982 &json!({
1983 "repoId": run.repo_id,
1984 "sha": run.sha,
1985 "context": format!("{} / {}", run.name, run.event),
1986 "state": "pending",
Merge branch 'worktree-agent-a3abfcce648e87dca'1987 "description": if run.status == "action_required" {
1988 format!("{} is waiting for approval", run.name)
1989 } else {
1990 format!("{} is running", run.name)
1991 },
Prices are what g1t pays plus 20%, from the first second1992 "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1993 "source": "actions",
Prices are what g1t pays plus 20%, from the first second1994 }),
1995 )
1996 .await;
1997 Ok(())
1998 }
1999
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2000 /// Cancels a run: its waiting and queued jobs at once, and its running
2001 /// ones gracefully (`stop_job`), or outright when `force`.
2002 pub async fn cancel_run(&self, run: &RunRow, reason: &str, force: bool) -> Result<()> {
Prices are what g1t pays plus 20%, from the first second2003 self.db
2004 .prepare("UPDATE runs SET conclusion = 'cancelled' WHERE id = ? AND status != 'completed'")
2005 .bind(&[run.id.as_str().into()])?
2006 .run()
2007 .await?;
2008 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)2009 if force {
2010 self.hard_stop(job, reason).await?;
2011 } else {
2012 self.stop_job(job, reason).await?;
2013 }
Prices are what g1t pays plus 20%, from the first second2014 }
Merge branch 'worktree-agent-a3abfcce648e87dca'2015 if run.status == "pending" || run.status == "action_required" {
Prices are what g1t pays plus 20%, from the first second2016 self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[run.id.as_str().into()])?.run().await?;
2017 }
2018 self.advance(&run.id).await
2019 }
2020
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2021 /// Cancels every run of the repository that has not finished, for
2022 /// `repo.deleted` and `repo.archived`.
2023 pub async fn stop_runs(&self, repo_id: &str) -> Result<()> {
2024 let runs = self
2025 .db
2026 .prepare("SELECT * FROM runs WHERE repo_id = ? AND status != 'completed'")
2027 .bind(&[repo_id.into()])?
2028 .all()
2029 .await?
2030 .results::<RunRow>()?;
2031 for run in runs {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2032 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 look2033 }
2034 Ok(())
2035 }
2036
Prices are what g1t pays plus 20%, from the first second2037 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 look2038 if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? {
Prices are what g1t pays plus 20%, from the first second2039 return Ok(Outcome::Fail(refused));
2040 }
2041 let run = check!(self.run_in(&a.repo, &a.id).await?);
2042 if run.status == "completed" {
2043 return Ok(fail(FailureCode::Conflict, "The run has already finished."));
2044 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2045 // Cancelling a run that is already cancelling stops its jobs
2046 // outright, without waiting for their cleanup steps.
2047 let force = a.force || run.conclusion.as_deref() == Some("cancelled");
2048 let reason = if force {
2049 format!("{} stopped the run without waiting for its cleanup steps.", a.actor.username)
2050 } else {
2051 format!("{} cancelled the run.", a.actor.username)
2052 };
2053 self.cancel_run(&run, &reason, force).await?;
Prices are what g1t pays plus 20%, from the first second2054 self.run_summary(&run.id).await
2055 }
2056
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2057 /// Runs again, as a new attempt: every job, with `failed_only` those
2058 /// that did not succeed, or with `job` that one; each with the jobs that
2059 /// need them. The attempt that ends is kept, its jobs and their logs and
2060 /// summaries with it (`job_attempts`, `run_attempts`).
Prices are what g1t pays plus 20%, from the first second2061 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 look2062 if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? {
Prices are what g1t pays plus 20%, from the first second2063 return Ok(Outcome::Fail(refused));
2064 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2065 // A job alone (`POST …/jobs/{job}/rerun`) names its run.
2066 let run_id = match (&a.job, a.id.is_empty()) {
2067 (Some(job), true) => {
2068 #[derive(Deserialize)]
2069 struct Of {
2070 run_id: String,
2071 }
2072 let of = self.db.prepare("SELECT run_id FROM jobs WHERE id = ?").bind(&[job.as_str().into()])?.first::<Of>(None).await?;
2073 match of {
2074 Some(of) => of.run_id,
2075 None => return Ok(fail(FailureCode::NotFound, "No such job.")),
2076 }
2077 }
2078 _ => a.id.clone(),
2079 };
2080 let run = check!(self.run_in(&a.repo, &run_id).await?);
Prices are what g1t pays plus 20%, from the first second2081 if run.status != "completed" {
2082 return Ok(fail(FailureCode::Conflict, "The run is still going: cancel it first."));
2083 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2084 // Nothing starts again on an archived repository.
2085 match self.visible_repo(&a.repo, &Some(a.actor.clone())).await? {
2086 Some(repo) if repo.archived() => {
2087 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
2088 }
2089 Some(_) => {}
2090 None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")),
2091 }
Prices are what g1t pays plus 20%, from the first second2092 if run.error.is_some() {
2093 return Ok(fail(FailureCode::Conflict, "This run never started: fix the workflow file and push again."));
2094 }
2095 let jobs = self.job_rows(&run.id).await?;
2096 let workflow = workflow::parse(&run.source).ok();
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2097 let target = match &a.job {
2098 Some(id) => match jobs.iter().find(|job| &job.id == id) {
2099 Some(job) => Some(top_key(&job.key).to_owned()),
2100 None => return Ok(fail(FailureCode::NotFound, "That job is not in the run's latest attempt.")),
2101 },
2102 None => None,
2103 };
2104 let which = match (&target, a.failed_only) {
2105 (Some(key), _) => Rerun::Job(key),
2106 (None, true) => Rerun::Failed,
2107 (None, false) => Rerun::All,
2108 };
2109 let order = workflow.as_ref().map(|w| w.job_order()).unwrap_or_default();
2110 let again = rerun_keys(
2111 &order,
2112 |key| jobs.iter().find(|j| j.key == key).map(JobRow::needs).unwrap_or_default(),
2113 |key| jobs.iter().filter(|j| j.key == key).all(|j| j.conclusion.as_deref() == Some("success")),
2114 which,
2115 );
Prices are what g1t pays plus 20%, from the first second2116 if again.is_empty() {
2117 return Ok(fail(FailureCode::Conflict, "Every job succeeded: there is nothing to run again."));
2118 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2119 // The jobs that run again, a called workflow's with the job calling it.
2120 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();
2121 let ids = serde_json::to_string(&rerun_ids)?;
2122 let ended = run.attempt.to_string();
2123 let ended = ended.as_str();
2124 let mut statements = vec![
2125 // The attempt that ends, as it ended.
2126 self.db
2127 .prepare(
2128 "INSERT OR REPLACE INTO run_attempts (run_id, attempt, repo_id, conclusion, actor, debug, started_at, finished_at)
2129 SELECT id, attempt, repo_id, conclusion, COALESCE(triggering_actor, actor), debug, started_at, finished_at FROM runs WHERE id = ?",
2130 )
2131 .bind(&[run.id.as_str().into()])?,
2132 // Earlier attempts that showed a job's logs from where it ran
2133 // then now find them where they move to.
2134 self.db
2135 .prepare("UPDATE job_attempts SET log_id = log_id || '.' || ?1 WHERE run_id = ?2 AND log_id IN (SELECT value FROM json_each(?3))")
2136 .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?,
2137 // Each of its jobs: one that runs again keeps its logs under
2138 // `{id}.{attempt}`; one left alone is still the live job's.
2139 self.db
2140 .prepare(
2141 "INSERT OR REPLACE INTO job_attempts (id, run_id, repo_id, attempt, job_id, log_id, key, ordinal, name, needs, status, conclusion,
2142 steps, annotations, reason, environment, labels, runner_name, started_at, finished_at)
2143 SELECT id || '.' || ?1, run_id, repo_id, ?1, id,
2144 CASE WHEN id IN (SELECT value FROM json_each(?3)) THEN id || '.' || ?1 ELSE id END,
2145 key, ordinal, name, needs, status, conclusion, steps, annotations, reason, environment, labels, runner_name, started_at, finished_at
2146 FROM jobs WHERE run_id = ?2 ORDER BY rowid",
2147 )
2148 .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?,
2149 self.db
2150 .prepare("UPDATE logs SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))")
2151 .bind(&[ended.into(), ids.as_str().into()])?,
2152 self.db
2153 .prepare("UPDATE job_summaries SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))")
2154 .bind(&[ended.into(), ids.as_str().into()])?,
2155 ];
Prices are what g1t pays plus 20%, from the first second2156 for key in &again {
2157 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 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r92158 // The jobs of a workflow it called (each matrix combination's
2159 // under `key[i]/`) are made again when it calls it again.
Prices are what g1t pays plus 20%, from the first second2160 statements.push(
2161 self.db
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r92162 .prepare("DELETE FROM jobs WHERE run_id = ? AND (key LIKE ? OR key LIKE ?)")
2163 .bind(&[run.id.as_str().into(), format!("{key}/%").into(), format!("{key}[%").into()])?,
Prices are what g1t pays plus 20%, from the first second2164 );
2165 statements.push(
2166 self.db
2167 .prepare(
2168 "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL,
Fast pages, required checks on the branch, self-hosted runners, honest incidents2169 matrix = NULL, call = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL,
Merge branch 'worktree-agent-a3abfcce648e87dca'2170 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)2171 concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?",
Prices are what g1t pays plus 20%, from the first second2172 )
2173 .bind(&[run.id.as_str().into(), key.as_str().into()])?,
2174 );
2175 }
2176 statements.push(
2177 self.db
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2178 .prepare(
2179 "UPDATE runs SET status = 'queued', conclusion = NULL, attempt = attempt + 1, started_at = NULL, finished_at = NULL,
2180 triggering_actor = ?, debug = ? WHERE id = ?",
2181 )
2182 .bind(&[a.actor.username.as_str().into(), u32::from(a.debug).into(), run.id.as_str().into()])?,
Prices are what g1t pays plus 20%, from the first second2183 );
2184 self.db.batch(statements).await?;
2185 if let Some(run) = self.run_row(&run.id).await? {
2186 self.report_pending(&run).await?;
2187 }
2188 self.advance(&run.id).await?;
2189 self.run_summary(&run.id).await
2190 }
2191
2192 pub async fn run_in(&self, repo: &RepoPath, id: &str) -> Result<Outcome<RunRow>> {
2193 let row = self
2194 .db
2195 .prepare("SELECT * FROM runs WHERE id = ? AND lower(repo) = lower(?)")
2196 .bind(&[id.into(), format!("{}/{}", repo.namespace, repo.name).into()])?
2197 .first::<RunRow>(None)
2198 .await?;
2199 Ok(row.map_or_else(|| fail(FailureCode::NotFound, "No such run."), Outcome::Ok))
2200 }
2201
2202 // --- The sandbox's side -----------------------------------------------------
2203
Fast pages, required checks on the branch, self-hosted runners, honest incidents2204 pub(crate) async fn job_for_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> {
Prices are what g1t pays plus 20%, from the first second2205 let job = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.job.as_str().into()])?.first::<JobRow>(None).await?;
2206 Ok(match job {
2207 Some(job) if job.status == "in_progress" && job.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(&a.token))) => {
2208 Outcome::Ok(job)
2209 }
2210 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
2211 })
2212 }
2213
2214 /// `job_auth`: which run and repository a running job's token is for,
2215 /// so the API can keep its artifacts and cache.
2216 pub async fn job_auth(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
2217 let job = check!(self.job_for_token(&a).await?);
2218 Ok(Outcome::Ok(json!({ "run": job.run_id, "repoId": job.repo_id })))
2219 }
2220
2221 /// `job_spec`: everything the sandbox needs to run the job.
2222 pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
2223 let job = check!(self.job_for_token(&a).await?);
2224 let Some(run) = self.run_row(&job.run_id).await? else {
2225 return Ok(fail(FailureCode::NotFound, "No such run."));
2226 };
2227 let Ok(caller) = workflow::parse(&run.source) else {
2228 return Ok(fail(FailureCode::Invalid, "The workflow no longer reads."));
2229 };
2230 // A called workflow's job runs as that workflow defines it.
2231 let callee = job.callee();
2232 let (workflow, spec, call_inputs) = match callee {
2233 Some((called, spec, call)) => (called, spec, Some(call["inputs"].clone())),
2234 None => match caller.jobs.iter().find(|j| j.id == job.key) {
2235 Some(spec) => (caller.clone(), spec.clone(), None),
2236 None => return Ok(fail(FailureCode::NotFound, "The job is not in the workflow.")),
2237 },
2238 };
2239 let spec = &spec;
2240 let repo = repo_path(&run.repo);
2241 let trusted = run.trusted != 0;
Merge branch 'worktree-agent-a3abfcce648e87dca'2242 // The job's `environment:`, by name, as read when its needs were done
2243 // (an expression included), once the environment's protection rules
2244 // let it start: entries with a value for it give that value instead
2245 // of their default, as GitHub's environment secrets do.
2246 let environment: Option<String> = job.environment.clone();
2247 // What its token may do: its `permissions:` (a called workflow's
2248 // jobs no more than the job that calls it), else the repository's
2249 // default; read-only for a pull request from outside.
2250 // The repository's default, its workspace's taken in, and whether
2251 // its jobs may open and approve pull requests.
2252 let (default, pull_requests) = self.token_policy(&run.repo_id).await?;
2253 let mut permissions = spec.permissions(&workflow, default);
2254 if let Some(parent) = &job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::to_owned)) {
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r92255 let top = top_key(parent);
Merge branch 'worktree-agent-a3abfcce648e87dca'2256 if let Some(caller_job) = caller.jobs.iter().find(|j| j.id == top) {
2257 permissions = permissions.capped_by(&caller_job.permissions(&caller, default));
Prices are what g1t pays plus 20%, from the first second2258 }
Merge branch 'worktree-agent-a3abfcce648e87dca'2259 }
2260 if !trusted {
2261 permissions = permissions.read_only();
2262 }
2263 // G1T_TOKEN, and GITHUB_TOKEN as its alias: a token of the
2264 // workspace's that reaches this repository only, with the scopes
2265 // its permissions give, until the job ends.
2266 let token = match self.workspace_actor(&repo.namespace).await? {
2267 Some(workspace) => {
2268 let created: CreatedAccessToken = g1t_kit::call(
2269 &self.identity,
2270 "create_job_token",
2271 &CreateJobTokenArgs {
2272 workspace,
2273 repo: repo.clone(),
2274 run_id: run.id.clone(),
2275 job_id: job.id.clone(),
2276 name: format!("G1T_TOKEN for {} run {}", run.repo, run.number),
2277 ttl_seconds: u64::from(job.timeout_minutes) * 60 + 600,
2278 scopes: permissions.scopes().into_iter().map(str::to_owned).collect(),
2279 pull_requests: pull_requests && trusted,
2280 },
2281 )
2282 .await?;
2283 created.token
2284 }
2285 None => String::new(),
Prices are what g1t pays plus 20%, from the first second2286 };
2287 // A run that is not trusted (a pull request from outside the
2288 // workspace) gets no secrets and an empty token.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2289 let passed = job.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned());
2290 let mut secrets = match (trusted, passed) {
2291 (false, _) => Map::new(),
2292 (true, None) => self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?,
2293 // A called workflow's job: what its callers passed it (reach.rs),
2294 // and its own environment's secrets over them.
2295 (true, Some(plan)) => {
2296 let base = self.secrets_for(&run.repo_id, &run.repo, None, true).await?;
2297 let vars = self.variables_for(&run.repo_id, &run.repo, None, true).await?;
2298 let github = run.info().context(&job.key, "", run.action.as_deref());
2299 let mut passed = crate::reach::resolve_secrets(&plan, &base, &github, &vars);
2300 if let Some(name) = environment.as_deref() {
2301 let own = self.secrets_for(&run.repo_id, &run.repo, Some(name), true).await?;
2302 for (key, value) in own {
2303 if base.get(&key) != Some(&value) {
2304 passed.insert(key, value);
2305 }
2306 }
2307 }
2308 passed
2309 }
Prices are what g1t pays plus 20%, from the first second2310 };
2311 secrets.insert("G1T_TOKEN".into(), Value::String(token.clone()));
2312 secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone()));
Merge branch 'worktree-agent-a3abfcce648e87dca'2313 // Each secret as it is, a line at a time, base64 and JSON-escaped.
2314 let mut masks: Vec<String> = g1t_actions::mask::all_variants(secrets.values().filter_map(|v| v.as_str()));
Prices are what g1t pays plus 20%, from the first second2315 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 R22316 // The toolkit's runtime token (runtime.rs), for as long as the job
2317 // may run; the API puts it and the toolkit's addresses in the
2318 // job's variables.
2319 let runtime_token = crate::runtime::runtime_token(
2320 &job.id,
2321 &job.run_id,
2322 job.token_hash.as_deref().unwrap_or_default(),
2323 now_ms() / 1000,
2324 u64::from(job.timeout_minutes) * 60 + 600,
2325 );
2326 masks.push(runtime_token.clone());
2327 let retention_days = self.retention_setting(&run.repo_id).await?;
Prices are what g1t pays plus 20%, from the first second2328
2329 let jobs = self.job_rows(&run.id).await?;
2330 let mut needs = Map::new();
2331 // In a called workflow, its jobs' keys sit under the job that called it.
2332 let parent = job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::to_owned));
2333 for need in &spec.needs {
2334 let key = match &parent {
2335 Some(parent) => format!("{parent}/{need}"),
2336 None => need.clone(),
2337 };
2338 let rows: Vec<&JobRow> = jobs.iter().filter(|row| row.key == key).collect();
2339 let mut outputs = Map::new();
2340 for row in &rows {
2341 if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) {
2342 outputs.extend(more);
2343 }
2344 }
2345 needs.insert(need.clone(), json!({ "result": key_result(&rows), "outputs": outputs }));
2346 }
2347 let siblings = jobs.iter().filter(|row| row.key == job.key).count();
2348 let matrix: Value = job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or(json!({}));
2349 let info = run.info();
2350 let mut github = info.context(&job.key, &token, run.action.as_deref());
2351 github["token"] = json!(token);
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R22352 github["retention_days"] = json!(retention_days);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2353 // On a self-hosted runner, `runner` and `RUNNER_*` describe that
2354 // machine rather than g1t's sandbox.
2355 let mut variables = info.variables(&job.key);
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R22356 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)2357 let mut runner = match &job.runner_id {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2358 Some(id) => self.runner_context_for(id, &mut variables).await?,
2359 None => runner_context(),
2360 };
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2361 // A re-run with debug logging: what GitHub sets for one.
2362 if run.debug != 0 {
2363 debug_logging(&mut variables, &mut runner);
2364 }
Prices are what g1t pays plus 20%, from the first second2365
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.2366 // Where to check out: the repository, which holds a pull request's
2367 // test merge commit; a pull request's fork when the run builds its
2368 // head instead (`run.sha` is the head, `info.sha` what is built).
Prices are what g1t pays plus 20%, from the first second2369 let clone_url = match run.pull {
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.2370 Some(number) if run.event.starts_with("pull_request") && run.event != "pull_request_target" && info.sha == run.sha => {
Prices are what g1t pays plus 20%, from the first second2371 let located: Outcome<g1t_contracts::work::PullDetail> = g1t_kit::call(
2372 &self.work,
2373 "get_pull",
2374 &g1t_contracts::work::ViewArgs {
2375 repo: repo.clone(),
2376 number,
2377 viewer: self.workspace_actor(&repo.namespace).await?,
2378 after_seq: 0,
2379 },
2380 )
2381 .await?;
2382 match located {
2383 Outcome::Ok(detail) => match detail.pull.fork {
2384 Some(fork) => format!("{SITE}/{}/{}.git", fork.namespace, fork.name),
2385 None => format!("{SITE}/{}.git", run.repo),
2386 },
2387 Outcome::Fail(_) => format!("{SITE}/{}.git", run.repo),
2388 }
2389 }
2390 _ => format!("{SITE}/{}.git", run.repo),
2391 };
2392
2393 Ok(Outcome::Ok(json!({
2394 "job": job.id,
2395 "run": run.id,
2396 "key": job.key,
2397 "name": job.name,
2398 "spec": spec.raw,
2399 "workflow": {
2400 "env": workflow.env,
2401 "defaults": workflow.raw.get("defaults").cloned().unwrap_or(Value::Null),
2402 },
2403 "github": github,
Fast pages, required checks on the branch, self-hosted runners, honest incidents2404 "variables": variables,
Prices are what g1t pays plus 20%, from the first second2405 "event": info.event,
2406 "contexts": {
2407 "vars": vars,
2408 "secrets": secrets,
2409 "inputs": call_inputs.unwrap_or_else(|| Value::Object(run.inputs())),
2410 "matrix": matrix,
2411 "needs": needs,
2412 "strategy": {
2413 "fail-fast": spec.fail_fast,
2414 "job-index": job.ordinal,
2415 "job-total": siblings,
2416 "max-parallel": spec.max_parallel.unwrap_or(siblings as u32),
2417 },
Fast pages, required checks on the branch, self-hosted runners, honest incidents2418 "runner": runner,
Prices are what g1t pays plus 20%, from the first second2419 },
2420 "checkout": {
2421 "repository": run.repo,
2422 "url": clone_url,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.2423 "sha": info.sha,
Prices are what g1t pays plus 20%, from the first second2424 "ref": run.git_ref,
2425 "token": token,
2426 },
2427 "timeoutMinutes": job.timeout_minutes,
2428 "masks": masks,
Merge branch 'worktree-agent-a3abfcce648e87dca'2429 // As the job's log lists them at its start.
2430 "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 R22431 // Whether the job may ask for an OIDC token decides whether it
2432 // is told where to.
2433 "runtime": {
2434 "token": runtime_token,
2435 "idToken": self.oidc_allowed(&run, &job),
2436 },
Prices are what g1t pays plus 20%, from the first second2437 })))
2438 }
2439
2440 /// `job_report`: the sandbox telling how the job is going.
2441 pub async fn job_report(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
2442 let job = check!(self.job_for_token(&a).await?);
2443 let report = &a.report;
2444 let at = now();
2445 match report["kind"].as_str().unwrap_or_default() {
2446 "steps" => {
2447 // The list can grow as the job goes (post steps), so steps
2448 // already reported keep where they stand.
2449 let known: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
2450 let steps: Vec<Value> = report["steps"]
2451 .as_array()
2452 .map(|names| {
2453 names
2454 .iter()
2455 .enumerate()
2456 .map(|(i, name)| match known.get(i) {
2457 Some(step) if step["status"] != "queued" => step.clone(),
2458 _ => json!({ "number": i + 1, "name": expr::to_text(name), "status": "queued", "conclusion": null, "startedAt": null, "finishedAt": null }),
2459 })
2460 .collect()
2461 })
2462 .unwrap_or_default();
2463 self.db
2464 .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?")
2465 .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])?
2466 .run()
2467 .await?;
2468 }
2469 "step" => {
2470 let number = report["number"].as_u64().unwrap_or(0) as usize;
2471 let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
2472 if let Some(step) = number.checked_sub(1).and_then(|i| steps.get_mut(i)) {
2473 let status = report["status"].as_str().unwrap_or("in_progress");
2474 step["status"] = json!(status);
2475 if status == "in_progress" {
2476 step["startedAt"] = json!(at);
2477 }
2478 if status == "completed" {
2479 step["finishedAt"] = json!(at);
2480 step["conclusion"] = report["conclusion"].clone();
2481 }
2482 if let Some(name) = report["name"].as_str() {
2483 step["name"] = json!(name);
2484 }
2485 }
2486 self.db
2487 .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?")
2488 .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])?
2489 .run()
2490 .await?;
2491 }
2492 "log" => {
2493 let mut text = report["text"].as_str().unwrap_or_default().to_owned();
2494 if text.len() > MAX_CHUNK_BYTES {
2495 let mut cut = MAX_CHUNK_BYTES;
2496 while !text.is_char_boundary(cut) {
2497 cut -= 1;
2498 }
2499 text.truncate(cut);
2500 }
2501 #[derive(Deserialize)]
2502 struct Size {
2503 n: Option<f64>,
2504 }
2505 let size = self
2506 .db
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.2507 .prepare("SELECT SUM(LENGTH(text)) AS n FROM logs WHERE job_id = ?")
Prices are what g1t pays plus 20%, from the first second2508 .bind(&[job.id.as_str().into()])?
2509 .first::<Size>(None)
2510 .await?;
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.2511 let used = size.and_then(|s| s.n).unwrap_or(0.0) as usize;
Prices are what g1t pays plus 20%, from the first second2512 if used < MAX_LOG_BYTES {
2513 if used + text.len() >= MAX_LOG_BYTES {
2514 text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n");
2515 }
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.2516 // The next `seq` is taken in the insert itself, so two
2517 // batches at once (a report sent again while the first
2518 // was still being written) never reach for the same one
2519 // and lose a chunk to the primary key.
Prices are what g1t pays plus 20%, from the first second2520 self.db
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.2521 .prepare(LOG_INSERT)
Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r92522 .bind(&[
2523 job.id.as_str().into(),
2524 (report["step"].as_u64().unwrap_or(0) as u32).into(),
2525 text.into(),
2526 report["chunk"].as_u64().map_or(worker::wasm_bindgen::JsValue::NULL, |n| (n as f64).into()),
2527 ])?
Prices are what g1t pays plus 20%, from the first second2528 .run()
2529 .await?;
2530 }
2531 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
2532 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2533 "summary" => {
2534 // `$GITHUB_STEP_SUMMARY`, masked by the runner: added to the
2535 // step's summary, up to 1 MiB a step and 20 steps a job.
2536 let step = report["step"].as_u64().unwrap_or(0) as u32;
2537 let markdown = report["markdown"].as_str().unwrap_or_default();
2538 #[derive(Deserialize)]
2539 struct Held {
2540 steps: u32,
2541 mine: Option<f64>,
2542 }
2543 let held = self
2544 .db
2545 .prepare("SELECT COUNT(*) AS steps, MAX(CASE WHEN step = ? THEN LENGTH(markdown) END) AS mine FROM job_summaries WHERE job_id = ?")
2546 .bind(&[step.into(), job.id.as_str().into()])?
2547 .first::<Held>(None)
2548 .await?;
2549 let (steps, mine) = held.map_or((0, None), |held| (held.steps, held.mine.map(|n| n as usize)));
2550 if summary_fits(steps, mine, markdown.len()) {
2551 self.db
2552 .prepare(
2553 "INSERT INTO job_summaries (job_id, step, markdown) VALUES (?, ?, ?)
2554 ON CONFLICT (job_id, step) DO UPDATE SET markdown = job_summaries.markdown || excluded.markdown",
2555 )
2556 .bind(&[job.id.as_str().into(), step.into(), markdown.into()])?
2557 .run()
2558 .await?;
2559 }
2560 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
2561 }
Prices are what g1t pays plus 20%, from the first second2562 "annotation" => {
2563 let mut annotations: Vec<Value> = serde_json::from_str(&job.annotations).unwrap_or_default();
2564 if annotations.len() < MAX_ANNOTATIONS {
2565 annotations.push(json!({
2566 "level": report["level"].as_str().unwrap_or("notice"),
2567 "message": report["message"].as_str().unwrap_or_default().chars().take(4000).collect::<String>(),
2568 "title": report["title"],
2569 "file": report["file"],
2570 "line": report["line"],
2571 }));
2572 self.db
2573 .prepare("UPDATE jobs SET annotations = ?, seen_at = ? WHERE id = ?")
2574 .bind(&[serde_json::to_string(&annotations)?.into(), at.as_str().into(), job.id.as_str().into()])?
2575 .run()
2576 .await?;
2577 }
2578 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2579 // Nothing to tell; the answer says whether to stop.
2580 "ping" => {
2581 self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
2582 }
Prices are what g1t pays plus 20%, from the first second2583 "done" => {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2584 // A job told to stop ends cancelled, however its cleanup went.
2585 let conclusion = if job.cancel_requested_at.is_some() {
2586 "cancelled"
2587 } else {
2588 report["conclusion"]
2589 .as_str()
2590 .filter(|c| matches!(*c, "success" | "failure" | "cancelled"))
2591 .unwrap_or("failure")
2592 };
Prices are what g1t pays plus 20%, from the first second2593 let outputs = report["outputs"].as_object().cloned();
2594 Box::pin(self.finish_job(&job.id, conclusion, report["reason"].as_str(), outputs.as_ref())).await?;
2595 }
2596 other => return Ok(fail(FailureCode::Invalid, format!("There is no report called `{other}`."))),
2597 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2598 // `cancelled`: the run was cancelled, and the runner should stop
2599 // the step it is on and run only its cleanup steps.
2600 Ok(Outcome::Ok(json!({ "ok": true, "cancelled": job.cancel_requested_at.is_some() })))
Prices are what g1t pays plus 20%, from the first second2601 }
2602
2603 // --- Every minute ---------------------------------------------------------------
2604
2605 pub async fn on_minute(&self, now_ms: u64) -> Result<()> {
2606 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)2607 // Staff (or billing's usage watcher) paused scheduled runs across
2608 // g1t: this minute's schedules are skipped, not queued for later.
2609 // Kept 30 seconds in the isolate (g1t_kit::pause).
2610 if g1t_kit::pause::paused(&self.billing, g1t_contracts::billing::PauseLevel::Schedules).await {
2611 worker::console_log!("actions: schedules are paused across g1t; skipped this minute's");
2612 } else if let Err(error) = self.run_schedules(minute).await {
Prices are what g1t pays plus 20%, from the first second2613 worker::console_error!("actions: schedules failed: {error}");
2614 }
2615 // Jobs whose sandbox went quiet or ran past their time.
2616 let running = self.db.prepare("SELECT * FROM jobs WHERE status = 'in_progress'").all().await?.results::<JobRow>()?;
2617 for job in running {
2618 // Times in g1t's format compare as text.
2619 let before = |ms: u64| rfc3339(now_ms.saturating_sub(ms));
2620 let silent = job.seen_at.as_deref().is_some_and(|seen| seen < before(SILENT_MS).as_str());
2621 let limit = (u64::from(job.timeout_minutes) * 60 + 120) * 1000;
2622 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)2623 let cancel_overdue = job.cancel_requested_at.as_deref().is_some_and(|at| at < before(CANCEL_GRACE_MS).as_str());
2624 if cancel_overdue {
2625 // Cancelled, and still going after its grace period.
2626 self.hard_stop(&job, "It was cancelled, and did not finish its cleanup steps within 5 minutes.").await?;
2627 self.advance(&job.run_id).await?;
2628 } else if over {
Prices are what g1t pays plus 20%, from the first second2629 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 incidents2630 // A self-hosted runner is told to stop on its next poll.
2631 if job.runner_id.is_none() {
2632 let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await;
2633 }
Prices are what g1t pays plus 20%, from the first second2634 self.finish_job(&job.id, "failure", Some(&reason), None).await?;
2635 } else if silent {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2636 let reason = match &job.runner_name {
2637 Some(name) => format!("The self-hosted runner {name} stopped answering."),
2638 None => "The runner stopped answering.".to_owned(),
2639 };
2640 self.finish_job(&job.id, "failure", Some(&reason), None).await?;
Prices are what g1t pays plus 20%, from the first second2641 }
2642 }
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r92643 // Calls of reusable workflows waiting in a concurrency group, in
2644 // case the job they waited for was cancelled in another run.
2645 #[derive(Deserialize)]
2646 struct RunId {
2647 run_id: String,
2648 }
2649 let held = self
2650 .db
2651 .prepare("SELECT DISTINCT run_id FROM jobs WHERE status = 'waiting' AND concurrency_group IS NOT NULL LIMIT 20")
2652 .all()
2653 .await?
2654 .results::<RunId>()?;
2655 for run in held {
2656 self.advance(&run.run_id).await?;
2657 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2658 if let Err(error) = self.sweep_runners(now_ms).await {
2659 worker::console_error!("actions: the runners' sweep failed: {error}");
2660 }
Merge branch 'worktree-agent-a3abfcce648e87dca'2661 // Jobs held at an environment whose wait timer has run out.
2662 if let Err(error) = self.release_gates().await {
2663 worker::console_error!("actions: environments' gates failed: {error}");
2664 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2665 // Once an hour: the cache's expired entries, and its storage.
2666 if (now_ms / 60_000) % 60 == 7
2667 && let Err(error) = self.sweep_cache(now_ms).await
2668 {
2669 worker::console_error!("actions: the cache's sweep failed: {error}");
2670 }
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'2671 // And the archives of GitHub's actions no job uses any more.
2672 if (now_ms / 60_000) % 60 == 47
2673 && let Err(error) = self.sweep_action_archives(now_ms).await
2674 {
2675 worker::console_error!("actions: the action archives' sweep failed: {error}");
2676 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R22677 // And artifacts past their time, and the toolkit's abandoned parts.
2678 if (now_ms / 60_000) % 60 == 37 {
2679 if let Err(error) = self.sweep_artifacts(now_ms).await {
2680 worker::console_error!("actions: the artifacts' sweep failed: {error}");
2681 }
2682 if let Err(error) = self.sweep_blob_parts(now_ms).await {
2683 worker::console_error!("actions: the blob parts' sweep failed: {error}");
2684 }
2685 }
Prices are what g1t pays plus 20%, from the first second2686 self.start_queued().await
2687 }
2688}
2689
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2690
2691#[cfg(test)]
Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r92692mod log_chunks {
2693 use rusqlite::{Connection, params};
2694
2695 use super::LOG_INSERT;
2696
2697 /// The logs table as the migrations leave it.
2698 fn database() -> Connection {
2699 let db = Connection::open_in_memory().unwrap();
2700 for name in ["0001_init.sql", "0016_log_chunks.sql"] {
2701 let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR"));
2702 db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
2703 }
2704 db
2705 }
2706
2707 fn log(db: &Connection, job: &str) -> Vec<(i64, i64, String)> {
2708 let mut rows = db.prepare("SELECT seq, step, text FROM logs WHERE job_id = ? ORDER BY seq").unwrap();
2709 rows.query_map([job], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect()
2710 }
2711
2712 #[test]
2713 fn a_batch_sent_again_is_kept_once() {
2714 let db = database();
2715 let insert = |job: &str, step: i64, text: &str, chunk: Option<i64>| db.execute(LOG_INSERT, params![job, step, text, chunk]).unwrap();
2716 assert_eq!(insert("job_1", 0, "one\n", Some(1)), 1);
2717 // The runner did not hear the answer and sent the batch again.
2718 assert_eq!(insert("job_1", 0, "one\n", Some(1)), 0);
2719 assert_eq!(insert("job_1", 1, "two\n", Some(2)), 1);
2720 // Another job numbers its batches from 1 too.
2721 assert_eq!(insert("job_2", 0, "other\n", Some(1)), 1);
2722 // A runner that numbers nothing has every batch kept, as before.
2723 assert_eq!(insert("job_3", 0, "a\n", None), 1);
2724 assert_eq!(insert("job_3", 0, "a\n", None), 1);
2725 assert_eq!(log(&db, "job_1"), [(1, 0, "one\n".into()), (2, 1, "two\n".into())]);
2726 assert_eq!(log(&db, "job_2"), [(1, 0, "other\n".into())]);
2727 assert_eq!(log(&db, "job_3").len(), 2);
2728 // A job run again moves its log aside (`{id}.{attempt}`), and the
2729 // new attempt's runner numbers from 1 again.
2730 db.execute("UPDATE logs SET job_id = job_id || '.1' WHERE job_id = 'job_1'", []).unwrap();
2731 assert_eq!(insert("job_1", 0, "again\n", Some(1)), 1);
2732 assert_eq!(log(&db, "job_1"), [(1, 0, "again\n".into())]);
2733 assert_eq!(log(&db, "job_1.1").len(), 2);
2734 }
2735}
2736
2737#[cfg(test)]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2738mod stopping {
2739 use super::stops_runs;
2740 use g1t_contracts::events::Event;
2741 use serde_json::{Value, json};
2742
2743 fn event(kind: &str, data: Value) -> Event {
2744 Event {
2745 id: "evt_1".into(),
2746 kind: kind.into(),
2747 source: "repos".into(),
2748 time: "2026-10-05T00:00:00Z".into(),
2749 repo_id: Some("rep_1".into()),
2750 actor: None,
2751 data,
2752 }
2753 }
2754
2755 #[test]
2756 fn deleting_or_archiving_stops_runs() {
2757 assert_eq!(stops_runs(&event("repo.deleted", json!({ "repoId": "rep_1" }))).as_deref(), Some("rep_1"));
2758 assert_eq!(stops_runs(&event("repo.archived", json!({ "archived": true }))).as_deref(), Some("rep_1"));
2759 assert_eq!(stops_runs(&event("repo.unarchived", json!({ "archived": false }))), None);
2760 assert_eq!(stops_runs(&event("repo.restored", json!({}))), None);
2761 assert_eq!(stops_runs(&event("git.push", json!({}))), None);
2762 }
2763}
Fast pages, required checks on the branch, self-hosted runners, honest incidents2764
2765#[cfg(test)]
2766mod status_of_needs {
2767 use std::collections::HashMap;
2768
2769 use super::ancestor_failed;
2770
2771 /// check -> plan -> (migrate) -> core -> edge, as deploy.yml has them,
2772 /// and a job that needs only the last.
2773 fn graph() -> HashMap<&'static str, Vec<&'static str>> {
2774 HashMap::from([
2775 ("check", vec![]),
2776 ("plan", vec!["check"]),
2777 ("migrate", vec!["plan"]),
2778 ("core", vec!["plan", "migrate"]),
2779 ("edge", vec!["plan", "migrate", "core"]),
2780 ("notify", vec!["edge"]),
2781 ])
2782 }
2783
2784 #[test]
2785 fn a_failure_is_seen_however_far_back() {
2786 let needs = graph();
2787 let failed = |which: &'static str| move |key: &str| key == which;
2788 // check failed; plan, and everything after, was skipped for it.
2789 assert!(ancestor_failed(&needs, "notify", failed("check")));
2790 assert!(ancestor_failed(&needs, "core", failed("check")));
2791 assert!(ancestor_failed(&needs, "edge", failed("core")));
2792 // Nothing before a job failed: a skipped migrate is not a failure.
2793 assert!(!ancestor_failed(&needs, "edge", |_| false));
2794 assert!(!ancestor_failed(&needs, "core", failed("edge")));
2795 assert!(!ancestor_failed(&needs, "check", failed("check")));
2796 }
2797
2798 #[test]
2799 fn cycles_and_unknown_keys_end() {
2800 let needs = HashMap::from([("a", vec!["b"]), ("b", vec!["a"])]);
2801 assert!(!ancestor_failed(&needs, "a", |_| false));
2802 assert!(!ancestor_failed(&needs, "missing", |_| true));
2803 }
2804}
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972805
2806#[cfg(test)]
2807mod deployments {
2808 use serde_json::{Map, Value, json};
2809
2810 use super::{JobEnvironment, deployment_outcome, environment_of};
2811
2812 #[test]
2813 fn a_jobs_environment_is_read_for_deployments() {
2814 let contexts: Map<String, Value> = serde_json::from_value(json!({
2815 "github": { "ref_name": "main", "repository": "acme/web" },
2816 "inputs": { "target": "staging" },
2817 "matrix": {},
2818 }))
2819 .unwrap();
2820 let read = |raw: Value| environment_of(&raw, &contexts);
2821 assert_eq!(read(json!({})), None);
2822 assert_eq!(
2823 read(json!({ "environment": "production" })),
2824 Some(JobEnvironment { name: "production".into(), url: None, deploys: true })
2825 );
2826 assert_eq!(
2827 read(json!({ "environment": { "name": "production", "url": "https://g1t.sh" } })),
2828 Some(JobEnvironment { name: "production".into(), url: Some("https://g1t.sh".into()), deploys: true })
2829 );
2830 // Expressions are filled in from the run.
2831 assert_eq!(
2832 read(json!({ "environment": { "name": "${{ inputs.target }}", "url": "https://${{ github.ref_name }}.example.com" } })),
2833 Some(JobEnvironment { name: "staging".into(), url: Some("https://main.example.com".into()), deploys: true })
2834 );
2835 // Secrets only: no deployment.
2836 assert!(!read(json!({ "environment": { "name": "production", "deployment": false } })).unwrap().deploys);
Merge branch 'worktree-agent-a641fa0e2e4fffccb' into claude/cool-newton-7nm8r92837 // The same, said in the job's g1t settings.
2838 assert!(!read(json!({ "environment": "production", "g1t": { "deployment": false } })).unwrap().deploys);
2839 assert!(!read(json!({ "environment": { "name": "production" }, "g1t": { "deployment": false } })).unwrap().deploys);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972840 // Only http(s) addresses.
2841 assert_eq!(read(json!({ "environment": { "name": "production", "url": "javascript:alert(1)" } })).unwrap().url, None);
2842 }
2843
2844 #[test]
2845 fn a_runs_outcome_for_an_environment() {
2846 let of = |list: &[&str]| deployment_outcome(&list.iter().map(|c| Some((*c).to_owned())).collect::<Vec<_>>());
2847 assert_eq!(of(&["success", "skipped"]), Some("success"));
2848 assert_eq!(of(&["success", "failure"]), Some("failure"));
2849 assert_eq!(of(&["success", "cancelled"]), Some("error"));
2850 assert_eq!(of(&["skipped"]), None);
2851 assert_eq!(deployment_outcome(&[None]), None);
2852 }
2853}
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)2854
2855#[cfg(test)]
2856mod reruns {
2857 use serde_json::{Map, Value, json};
2858
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r92859 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)2860
2861 /// build ← test ← deploy, and lint on its own.
2862 fn keys(which: Rerun, failed: &[&str]) -> Vec<String> {
2863 let order = ["build", "lint", "test", "deploy"];
2864 let needs = |key: &str| -> Vec<String> {
2865 match key {
2866 "test" => vec!["build".into()],
2867 "deploy" => vec!["test".into()],
2868 _ => Vec::new(),
2869 }
2870 };
2871 rerun_keys(&order, needs, |key| !failed.contains(&key), which)
2872 }
2873
2874 #[test]
2875 fn a_rerun_takes_the_jobs_it_names_and_those_that_need_them() {
2876 assert_eq!(keys(Rerun::All, &[]), ["build", "lint", "test", "deploy"]);
2877 assert_eq!(keys(Rerun::Failed, &["test"]), ["test", "deploy"]);
2878 assert_eq!(keys(Rerun::Failed, &["lint"]), ["lint"]);
2879 assert!(keys(Rerun::Failed, &[]).is_empty());
2880 // One job, whatever it came to, and what depends on it.
2881 assert_eq!(keys(Rerun::Job("build"), &[]), ["build", "test", "deploy"]);
2882 assert_eq!(keys(Rerun::Job("lint"), &["test"]), ["lint"]);
2883 assert_eq!(keys(Rerun::Job("deploy"), &[]), ["deploy"]);
2884 assert!(keys(Rerun::Job("missing"), &[]).is_empty());
2885 }
2886
2887 #[test]
2888 fn a_called_workflows_jobs_run_again_with_the_job_that_calls_it() {
2889 assert_eq!(top_key("build/test"), "build");
2890 assert_eq!(top_key("build/inner/test"), "build");
2891 assert_eq!(top_key("lint"), "lint");
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r92892 // A matrix's calls each have their own jobs, under `key[i]/`.
2893 assert_eq!(top_key("build[1]/test"), "build");
2894 assert_eq!(top_key("build/inner[0]/test"), "build");
2895 assert_eq!(call_prefix("build", Some(1)), "build[1]");
2896 assert_eq!(call_prefix("build/inner", None), "build/inner");
2897 }
2898
2899 #[test]
2900 fn a_run_connects_ten_levels_of_workflows_and_fifty_reusable_ones() {
2901 assert!(call_allowed(1, &[], "a.yml").is_ok());
2902 assert!(call_allowed(9, &[], "a.yml").is_ok());
2903 let deep = call_allowed(10, &[], "a.yml").unwrap_err();
2904 assert!(deep.contains("at most ten") && deep.contains("nine levels"), "{deep}");
2905 let names: Vec<String> = (0..50).map(|n| format!("w{n}.yml")).collect();
2906 let fifty: Vec<&str> = names.iter().map(String::as_str).collect();
2907 // A workflow called again, as by each combination of a matrix, is one.
2908 assert!(call_allowed(1, &fifty, "w3.yml").is_ok());
2909 assert!(call_allowed(1, &fifty, "another.yml").unwrap_err().contains("at most 50 different"));
2910 }
2911
2912 #[test]
2913 fn a_matrixs_calls_are_named_by_the_caller_and_its_values() {
2914 let workflow = super::workflow::parse(
2915 "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' }]",
2916 )
2917 .unwrap();
2918 let job = |id: &str| workflow.jobs.iter().find(|job| job.id == id).unwrap();
2919 let combination: Map<String, Value> = serde_json::from_value(json!({ "os": "linux", "node": 20 })).unwrap();
2920 let mut contexts = Map::new();
2921 contexts.insert("matrix".into(), Value::Object(combination.clone()));
2922 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
2923 assert_eq!(shown_name(job("build"), None, &combination, &scope), "Build (linux, 20)");
2924 assert_eq!(shown_name(job("plain"), None, &Map::new(), &scope), "plain");
2925 // A called workflow's job, under the call that made it.
2926 assert_eq!(shown_name(job("plain"), Some("Build (linux, 20)"), &Map::new(), &scope), "Build (linux, 20) / plain");
2927 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)2928 }
2929
2930 #[test]
2931 fn a_job_keeps_twenty_steps_summaries_of_a_mebibyte_each() {
2932 assert!(summary_fits(0, None, 10));
2933 assert!(!summary_fits(0, None, 0), "nothing to keep");
2934 assert!(!summary_fits(MAX_SUMMARIES, None, 10), "a twenty-first step's summary is dropped");
2935 assert!(summary_fits(MAX_SUMMARIES, Some(10), 10), "a step that has one may add to it");
2936 assert!(summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 10));
2937 assert!(!summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 11));
2938 assert!(!summary_fits(0, None, MAX_SUMMARY_BYTES + 1));
2939 }
2940
2941 #[test]
2942 fn a_debug_rerun_sets_what_github_sets() {
2943 let mut variables = Map::new();
2944 let mut runner = json!({ "name": "g1t", "debug": "" });
2945 debug_logging(&mut variables, &mut runner);
2946 assert_eq!(variables["RUNNER_DEBUG"], "1");
2947 assert_eq!(variables["ACTIONS_STEP_DEBUG"], "true");
2948 assert_eq!(variables["ACTIONS_RUNNER_DEBUG"], "true");
2949 assert_eq!(runner["debug"], Value::String("1".into()));
2950 assert_eq!(runner["name"], "g1t");
2951 }
2952}

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