Commit

GitHub Actions on g1t, part two: running workflows

A repository's .github/workflows run on g1t as they are. The new g1t-actions service reads them at the commit an event is about, matches their triggers and filters (push, pull_request and pull_request_target, issues, issue_comment, pull_request_review, schedule, workflow_dispatch), and makes runs. Each job waits for the jobs it needs, is skipped by its if, expanded into its matrix, held to fail-fast, max-parallel and concurrency groups, and started in a sandbox while its workspace has room. Runs, jobs, step states, annotations and logs are kept in D1. In the sandbox, MODE=actions runs a job as GitHub's runner does: run steps in bash, sh, python or a custom shell; JavaScript and composite actions fetched from GitHub; actions/checkout done natively against g1t; the ${{ }} contexts, the GITHUB_* variables and files, workflow commands and masked secrets. The image gains sudo and GitHub's /home/runner layout. Secrets (sealed) and variables belong to a repository or its workspace. The API gains 14 operations on GitHub's own paths under /repos/{owner}/{repo}/actions, and agents may read runs and logs. A run on a pull request's head is a commit status there. Pending statuses hold a merge and the lifecycle; failed ones refuse the merge and send a g1t agent back to fix the cause, using those tools.

syntaqxcommitted Parent005edd6Browse files
42 files+5919−1890/42 viewed
+18−0
880880 ]
881881
882882 [[package]]
883+name = "g1t-actions-service"
884+version = "0.1.0"
885+dependencies = [
886+ "g1t-actions",
887+ "g1t-contracts",
888+ "g1t-kit",
889+ "g1t-secrets",
890+ "serde",
891+ "serde_json",
892+ "worker",
893+]
894+
895+[[package]]
883896 name = "g1t-api"
884897 version = "0.1.0"
885898 dependencies = [
896909 name = "g1t-automations"
897910 version = "0.1.0"
898911 dependencies = [
912+ "g1t-actions",
899913 "g1t-contracts",
900914 "g1t-kit",
901915 "serde",
9991013 dependencies = [
10001014 "anyhow",
10011015 "base64 0.22.1",
1016+ "g1t-actions",
1017+ "hex",
10021018 "serde",
10031019 "serde_json",
1020+ "serde_yaml",
1021+ "sha2 0.10.9",
10041022 "ureq",
10051023 ]
10061024
+2−1
11 [workspace]
22 resolver = "3"
3−members = ["apps/api", "crates/*", "services/automations", "services/billing", "services/events", "services/identity", "services/integrations", "services/webhooks", "services/repos", "services/work"]
3+members = ["apps/api", "crates/*", "services/actions", "services/automations", "services/billing", "services/events", "services/identity", "services/integrations", "services/webhooks", "services/repos", "services/work"]
44
55 [workspace.package]
66 edition = "2024"
88 repository = "https://g1t.sh/syntaqx/g1t"
99
1010 [workspace.dependencies]
11+g1t-actions = { path = "crates/actions" }
1112 g1t-contracts = { path = "crates/contracts" }
1213 g1t-kit = { path = "crates/kit" }
1314 g1t-secrets = { path = "crates/secrets" }
+20−0
397397 let entry_id = path.trim_start_matches("/queue/").to_owned();
398398 return report_queue(&mut request, &services, &entry_id).await;
399399 }
400+ // A sandbox running a GitHub Actions job: fetching the job, and
401+ // reporting how it goes. The job's own token is the credential.
402+ ("POST", path) if path.starts_with("/actions/jobs/") => {
403+ let rest = path.trim_start_matches("/actions/jobs/");
404+ let (job, method) = match rest.strip_suffix("/spec") {
405+ Some(job) => (job.to_owned(), "job_spec"),
406+ None => (rest.to_owned(), "job_report"),
407+ };
408+ let body = json_body(&mut request).await;
409+ let answered: Outcome<Value> = g1t_kit::call(
410+ &services.actions,
411+ method,
412+ &json!({ "job": job, "token": body["token"], "report": body["report"] }),
413+ )
414+ .await?;
415+ return match answered {
416+ Outcome::Ok(value) => Response::from_json(&value),
417+ Outcome::Fail(refused) => failure(&refused),
418+ };
419+ }
400420 ("POST", path) if path.starts_with("/checks/") => {
401421 let run_id = path.trim_start_matches("/checks/").to_owned();
402422 return report_checks(&mut request, &services, &run_id).await;
+13−2
1212 "Webhooks"
1313 } else if name.contains("automation") {
1414 "Automations"
15+ } else if name.contains("workflow") || name.contains("actions_") || name == "get_job_logs" {
16+ "Actions"
1517 } else if name.contains("integration") || name.contains("model_routes") || op == Op::GetContext {
1618 "Integrations"
1719 } else if op == Op::Whoami || name.contains("workspace") {
110112
111113 // An operation reached at a workspace's address as well as a
112114 // repository's is documented once for each, with its own id.
115+ // GitHub's alternative addresses for one operation keep GitHub's names.
116+ let base = match (route.method, route.path.rsplit('/').next().unwrap_or_default()) {
117+ ("PUT", "enable") => "enable_workflow".to_owned(),
118+ ("PUT", "disable") => "disable_workflow".to_owned(),
119+ ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(),
120+ ("PATCH", ":setting") => "update_actions_variable".to_owned(),
121+ ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(),
122+ _ => op.name().to_owned(),
123+ };
113124 let id = if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) {
114− format!("{}_for_workspace", op.name())
125+ format!("{base}_for_workspace")
115126 } else {
116− op.name().to_owned()
127+ base
117128 };
118129 let mut described = json!({
119130 "operationId": id,
+260−1
2626 pub integrations: Fetcher,
2727 pub webhooks: Fetcher,
2828 pub automations: Fetcher,
29+ pub actions: Fetcher,
2930 /// Set for a request made with an agent's token: all it may do.
3031 pub scope: Option<AgentScope>,
3132 }
4243 integrations: env.service("INTEGRATIONS")?,
4344 webhooks: env.service("WEBHOOKS")?,
4445 automations: env.service("AUTOMATIONS")?,
46+ actions: env.service("ACTIONS")?,
4547 scope: None,
4648 })
4749 }
103105 ListAutomationRuns,
104106 RunAutomation,
105107 UpdateAutomation,
108+ ListWorkflows,
109+ ListWorkflowRuns,
110+ GetWorkflowRun,
111+ GetJobLogs,
112+ DispatchWorkflow,
113+ CancelWorkflowRun,
114+ RerunWorkflowRun,
115+ UpdateWorkflow,
116+ ListActionsSecrets,
117+ SetActionsSecret,
118+ DeleteActionsSecret,
119+ ListActionsVariables,
120+ SetActionsVariable,
121+ DeleteActionsVariable,
106122 }
107123
108124 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
234250 Value::Object(out)
235251 }
236252
253+/// The inputs that say whose secrets or variables: a repository's, or a
254+/// workspace's own.
255+fn settings_owner(properties: Value) -> Value {
256+ let mut properties = properties;
257+ properties["repo"] = json!({
258+ "type": "string",
259+ "description": "Repository as \"owner/name\", for its own.",
260+ });
261+ properties["workspace"] = json!({
262+ "type": "string",
263+ "description": "Instead of repo: the workspace, for the ones every repository in it reads.",
264+ });
265+ properties
266+}
267+
237268 /// The inputs that say whose webhooks: a repository's, or a workspace's own.
238269 fn hook_owner(properties: Value) -> Value {
239270 let mut properties = properties;
260291 }
261292
262293 impl Op {
263− pub const ALL: [Op; 54] = [
294+ pub const ALL: [Op; 68] = [
264295 Op::Whoami,
265296 Op::CreateWorkspace,
266297 Op::ListRepos,
315346 Op::ListAutomationRuns,
316347 Op::RunAutomation,
317348 Op::UpdateAutomation,
349+ Op::ListWorkflows,
350+ Op::ListWorkflowRuns,
351+ Op::GetWorkflowRun,
352+ Op::GetJobLogs,
353+ Op::DispatchWorkflow,
354+ Op::CancelWorkflowRun,
355+ Op::RerunWorkflowRun,
356+ Op::UpdateWorkflow,
357+ Op::ListActionsSecrets,
358+ Op::SetActionsSecret,
359+ Op::DeleteActionsSecret,
360+ Op::ListActionsVariables,
361+ Op::SetActionsVariable,
362+ Op::DeleteActionsVariable,
318363 ];
319364
320365 pub fn by_name(name: &str) -> Option<Op> {
378423 Op::ListAutomationRuns => "list_automation_runs",
379424 Op::RunAutomation => "run_automation",
380425 Op::UpdateAutomation => "update_automation",
426+ Op::ListWorkflows => "list_workflows",
427+ Op::ListWorkflowRuns => "list_workflow_runs",
428+ Op::GetWorkflowRun => "get_workflow_run",
429+ Op::GetJobLogs => "get_job_logs",
430+ Op::DispatchWorkflow => "dispatch_workflow",
431+ Op::CancelWorkflowRun => "cancel_workflow_run",
432+ Op::RerunWorkflowRun => "rerun_workflow_run",
433+ Op::UpdateWorkflow => "update_workflow",
434+ Op::ListActionsSecrets => "list_actions_secrets",
435+ Op::SetActionsSecret => "set_actions_secret",
436+ Op::DeleteActionsSecret => "delete_actions_secret",
437+ Op::ListActionsVariables => "list_actions_variables",
438+ Op::SetActionsVariable => "set_actions_variable",
439+ Op::DeleteActionsVariable => "delete_actions_variable",
381440 }
382441 }
383442
520579 "Run an automation now, on an issue or pull request if number is given. Members only."
521580 }
522581 Op::UpdateAutomation => "Turn an automation on or off without changing its file. Members only.",
582+ Op::ListWorkflows => {
583+ "A repository's GitHub Actions workflows, read from .github/workflows on its default branch: the events that start each, whether it is on, any problem with its file, notes on anything that runs differently on g1t, its manual-run inputs, and its last run."
584+ }
585+ Op::ListWorkflowRuns => {
586+ "A repository's workflow runs, newest first: of one workflow (its id or file name), a branch, an event, a pull request's number, or a commit."
587+ }
588+ Op::GetWorkflowRun => {
589+ "One workflow run with its jobs: each job's steps and how they went, its annotations (::error:: and the like), and why it stopped. Read a job's log with get_job_logs."
590+ }
591+ Op::GetJobLogs => {
592+ "A job's log, in order, after `after` (a sequence number from an earlier call). `done` says whether more will come. Lines starting ##[group], ##[endgroup], ##[error] and ##[warning] mark groups and messages."
593+ }
594+ Op::DispatchWorkflow => {
595+ "Run a workflow that has `on: workflow_dispatch`, on a branch or tag (the default branch if none), with its inputs. Members only."
596+ }
597+ Op::CancelWorkflowRun => "Cancel a run that is still going: its waiting jobs are cancelled and its running ones stopped. Members only.",
598+ Op::RerunWorkflowRun => {
599+ "Run a finished workflow run again: every job, or with failed_only the jobs that did not succeed and the jobs that need them. Members only."
600+ }
601+ Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Members only.",
602+ Op::ListActionsSecrets => {
603+ "The names of the secrets workflows read as `secrets.NAME`: a repository's, with the ones it inherits from its workspace, or a workspace's. Values are never returned. Members only."
604+ }
605+ Op::SetActionsSecret => {
606+ "Add or replace a secret. A repository's need a member; a workspace's, which every repository in it reads, an owner. Names are letters, digits and underscores, upper-cased."
607+ }
608+ Op::DeleteActionsSecret => "Remove a secret.",
609+ Op::ListActionsVariables => {
610+ "The variables workflows read as `vars.NAME`, with their values: a repository's, with the ones it inherits from its workspace, or a workspace's. Members only."
611+ }
612+ Op::SetActionsVariable => "Add or replace a variable, as for secrets.",
613+ Op::DeleteActionsVariable => "Remove a variable.",
523614 Op::ImportIssue => {
524615 "Open an issue from a ticket in Jira or Linear, or from a Sentry issue, by its key or address. The issue is linked to it: agents read the original, and when the work lands the ticket is told. Importing the same ticket again returns the issue already made. With assign, a g1t agent starts on it."
525616 }
863954 Op::GetModelRoutes => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
864955 Op::ListWebhooks => object(hook_owner(json!({})), &[]),
865956 Op::ListAutomations => repo_only(),
957+ Op::ListWorkflows => repo_only(),
958+ Op::ListWorkflowRuns => object(
959+ json!({
960+ "repo": repo_schema(),
961+ "workflow": { "type": "string", "description": "A workflow's id or file name, such as ci.yml." },
962+ "branch": { "type": "string" },
963+ "event": { "type": "string", "description": "push, pull_request, schedule, workflow_dispatch…" },
964+ "pull": { "type": "integer", "description": "A pull request's number." },
965+ "sha": { "type": "string", "description": "A commit." },
966+ "limit": { "type": "integer", "description": "At most 100; 50 if not given." },
967+ }),
968+ &["repo"],
969+ ),
970+ Op::GetWorkflowRun => object(
971+ json!({ "repo": repo_schema(), "id": { "type": "string", "description": "The run's id." } }),
972+ &["repo", "id"],
973+ ),
974+ Op::GetJobLogs => object(
975+ json!({
976+ "repo": repo_schema(),
977+ "job": { "type": "string", "description": "The job's id, from get_workflow_run." },
978+ "after": { "type": "integer", "description": "Only chunks after this sequence number." },
979+ }),
980+ &["repo", "job"],
981+ ),
982+ Op::DispatchWorkflow => object(
983+ json!({
984+ "repo": repo_schema(),
985+ "workflow": { "type": "string", "description": "The workflow's id or file name." },
986+ "ref": { "type": "string", "description": "A branch or tag. The default branch if not given." },
987+ "inputs": { "type": "object", "description": "The workflow_dispatch inputs, by name." },
988+ }),
989+ &["repo", "workflow"],
990+ ),
991+ Op::CancelWorkflowRun => object(
992+ json!({ "repo": repo_schema(), "id": { "type": "string", "description": "The run's id." } }),
993+ &["repo", "id"],
994+ ),
995+ Op::RerunWorkflowRun => object(
996+ json!({
997+ "repo": repo_schema(),
998+ "id": { "type": "string", "description": "The run's id." },
999+ "failed_only": { "type": "boolean", "description": "Only the jobs that did not succeed, and those that need them." },
1000+ }),
1001+ &["repo", "id"],
1002+ ),
1003+ Op::UpdateWorkflow => object(
1004+ json!({
1005+ "repo": repo_schema(),
1006+ "workflow": { "type": "string", "description": "The workflow's id or file name." },
1007+ "enabled": { "type": "boolean" },
1008+ }),
1009+ &["repo", "workflow", "enabled"],
1010+ ),
1011+ Op::ListActionsSecrets | Op::ListActionsVariables => object(settings_owner(json!({})), &[]),
1012+ Op::SetActionsSecret | Op::SetActionsVariable => object(
1013+ settings_owner(json!({
1014+ "setting": { "type": "string", "description": "The name, such as NPM_TOKEN." },
1015+ "value": { "type": "string" },
1016+ })),
1017+ &["setting", "value"],
1018+ ),
1019+ Op::DeleteActionsSecret | Op::DeleteActionsVariable => object(
1020+ settings_owner(json!({ "setting": { "type": "string", "description": "The name." } })),
1021+ &["setting"],
1022+ ),
8661023 Op::ListAutomationRuns => object(
8671024 json!({
8681025 "repo": repo_schema(),
10061163 | Op::PingWebhook
10071164 | Op::ListWebhookDeliveries
10081165 | Op::RedeliverWebhook
1166+ | Op::ListActionsSecrets
1167+ | Op::SetActionsSecret
1168+ | Op::DeleteActionsSecret
1169+ | Op::ListActionsVariables
1170+ | Op::SetActionsVariable
1171+ | Op::DeleteActionsVariable
10091172 )
10101173 }
10111174
10841247 integrations,
10851248 webhooks,
10861249 automations,
1250+ actions,
10871251 ..
10881252 } = services;
10891253 let workspace = || text(input, "workspace").to_lowercase();
15321696 )
15331697 .await
15341698 }
1699+ Op::ListWorkflows => pass(actions, "workflows", &json!({ "repo": repo, "viewer": viewer })).await,
1700+ Op::ListWorkflowRuns => {
1701+ pass(
1702+ actions,
1703+ "runs",
1704+ &json!({
1705+ "repo": repo,
1706+ "viewer": viewer,
1707+ "workflow": optional_text(input, "workflow"),
1708+ "branch": optional_text(input, "branch"),
1709+ "event": optional_text(input, "event"),
1710+ "pull": integer(input, "pull"),
1711+ "sha": optional_text(input, "sha"),
1712+ "limit": integer(input, "limit"),
1713+ }),
1714+ )
1715+ .await
1716+ }
1717+ Op::GetWorkflowRun => pass(actions, "run", &json!({ "repo": repo, "viewer": viewer, "id": text(input, "id") })).await,
1718+ Op::GetJobLogs => {
1719+ pass(
1720+ actions,
1721+ "logs",
1722+ &json!({ "repo": repo, "viewer": viewer, "job": text(input, "job"), "after": integer(input, "after").unwrap_or(0) }),
1723+ )
1724+ .await
1725+ }
1726+ Op::DispatchWorkflow => {
1727+ pass(
1728+ actions,
1729+ "dispatch",
1730+ &json!({
1731+ "actor": actor(),
1732+ "repo": repo,
1733+ "workflow": text(input, "workflow"),
1734+ "ref": optional_text(input, "ref"),
1735+ "inputs": if input["inputs"].is_object() { input["inputs"].clone() } else { json!({}) },
1736+ }),
1737+ )
1738+ .await
1739+ }
1740+ Op::CancelWorkflowRun | Op::RerunWorkflowRun => {
1741+ pass(
1742+ actions,
1743+ if self == Op::CancelWorkflowRun { "cancel" } else { "rerun" },
1744+ &json!({
1745+ "actor": actor(),
1746+ "repo": repo,
1747+ "id": text(input, "id"),
1748+ "failed_only": input["failed_only"].as_bool() == Some(true),
1749+ }),
1750+ )
1751+ .await
1752+ }
1753+ Op::UpdateWorkflow => {
1754+ pass(
1755+ actions,
1756+ "set_workflow_enabled",
1757+ &json!({
1758+ "actor": actor(),
1759+ "repo": repo,
1760+ "workflow": text(input, "workflow"),
1761+ "enabled": input["enabled"].as_bool() == Some(true),
1762+ }),
1763+ )
1764+ .await
1765+ }
1766+ Op::ListActionsSecrets
1767+ | Op::SetActionsSecret
1768+ | Op::DeleteActionsSecret
1769+ | Op::ListActionsVariables
1770+ | Op::SetActionsVariable
1771+ | Op::DeleteActionsVariable => {
1772+ let mut args = match repo_path(input) {
1773+ Some(repo) => json!({ "repo": repo }),
1774+ None if !workspace().is_empty() => json!({ "workspace": workspace() }),
1775+ None => return failed(FailureCode::Invalid, "Name the repository as repo, or the workspace as workspace."),
1776+ };
1777+ let kind = if matches!(self, Op::ListActionsSecrets | Op::SetActionsSecret | Op::DeleteActionsSecret) {
1778+ "secret"
1779+ } else {
1780+ "variable"
1781+ };
1782+ args["actor"] = json!(actor());
1783+ args["kind"] = json!(kind);
1784+ // GitHub's variables API names the variable in the body as `name`.
1785+ args["name"] = json!(optional_text(input, "setting").or_else(|| optional_text(input, "name")).unwrap_or_default());
1786+ args["value"] = json!(text(input, "value"));
1787+ let method = match self {
1788+ Op::ListActionsSecrets | Op::ListActionsVariables => "settings",
1789+ Op::SetActionsSecret | Op::SetActionsVariable => "set_setting",
1790+ _ => "delete_setting",
1791+ };
1792+ pass(actions, method, &args).await
1793+ }
15351794 Op::ListWebhooks
15361795 | Op::CreateWebhook
15371796 | Op::UpdateWebhook
+164−1
265265 Op::UpdateAutomation,
266266 &[],
267267 ),
268+ route(
269+ "GET",
270+ "/repos/:owner/:name/actions/workflows",
271+ Op::ListWorkflows,
272+ &[],
273+ ),
274+ route(
275+ "GET",
276+ "/repos/:owner/:name/actions/workflows/:workflow/runs",
277+ Op::ListWorkflowRuns,
278+ &[("branch", "branch"), ("event", "event"), ("per_page", "limit")],
279+ ),
280+ route(
281+ "POST",
282+ "/repos/:owner/:name/actions/workflows/:workflow/dispatches",
283+ Op::DispatchWorkflow,
284+ &[],
285+ ),
286+ route(
287+ "PATCH",
288+ "/repos/:owner/:name/actions/workflows/:workflow",
289+ Op::UpdateWorkflow,
290+ &[],
291+ ),
292+ route(
293+ "PUT",
294+ "/repos/:owner/:name/actions/workflows/:workflow/enable",
295+ Op::UpdateWorkflow,
296+ &[],
297+ ),
298+ route(
299+ "PUT",
300+ "/repos/:owner/:name/actions/workflows/:workflow/disable",
301+ Op::UpdateWorkflow,
302+ &[],
303+ ),
304+ route(
305+ "GET",
306+ "/repos/:owner/:name/actions/runs",
307+ Op::ListWorkflowRuns,
308+ &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("per_page", "limit")],
309+ ),
310+ route(
311+ "GET",
312+ "/repos/:owner/:name/actions/runs/:id",
313+ Op::GetWorkflowRun,
314+ &[],
315+ ),
316+ route(
317+ "POST",
318+ "/repos/:owner/:name/actions/runs/:id/cancel",
319+ Op::CancelWorkflowRun,
320+ &[],
321+ ),
322+ route(
323+ "POST",
324+ "/repos/:owner/:name/actions/runs/:id/rerun",
325+ Op::RerunWorkflowRun,
326+ &[],
327+ ),
328+ route(
329+ "POST",
330+ "/repos/:owner/:name/actions/runs/:id/rerun-failed-jobs",
331+ Op::RerunWorkflowRun,
332+ &[],
333+ ),
334+ route(
335+ "GET",
336+ "/repos/:owner/:name/actions/jobs/:job/logs",
337+ Op::GetJobLogs,
338+ &[("after", "after")],
339+ ),
340+ route(
341+ "GET",
342+ "/repos/:owner/:name/actions/secrets",
343+ Op::ListActionsSecrets,
344+ &[],
345+ ),
346+ route(
347+ "PUT",
348+ "/repos/:owner/:name/actions/secrets/:setting",
349+ Op::SetActionsSecret,
350+ &[],
351+ ),
352+ route(
353+ "DELETE",
354+ "/repos/:owner/:name/actions/secrets/:setting",
355+ Op::DeleteActionsSecret,
356+ &[],
357+ ),
358+ route(
359+ "GET",
360+ "/repos/:owner/:name/actions/variables",
361+ Op::ListActionsVariables,
362+ &[],
363+ ),
364+ route(
365+ "POST",
366+ "/repos/:owner/:name/actions/variables",
367+ Op::SetActionsVariable,
368+ &[],
369+ ),
370+ route(
371+ "PATCH",
372+ "/repos/:owner/:name/actions/variables/:setting",
373+ Op::SetActionsVariable,
374+ &[],
375+ ),
376+ route(
377+ "DELETE",
378+ "/repos/:owner/:name/actions/variables/:setting",
379+ Op::DeleteActionsVariable,
380+ &[],
381+ ),
382+ route(
383+ "GET",
384+ "/workspaces/:workspace/actions/secrets",
385+ Op::ListActionsSecrets,
386+ &[],
387+ ),
388+ route(
389+ "PUT",
390+ "/workspaces/:workspace/actions/secrets/:setting",
391+ Op::SetActionsSecret,
392+ &[],
393+ ),
394+ route(
395+ "DELETE",
396+ "/workspaces/:workspace/actions/secrets/:setting",
397+ Op::DeleteActionsSecret,
398+ &[],
399+ ),
400+ route(
401+ "GET",
402+ "/workspaces/:workspace/actions/variables",
403+ Op::ListActionsVariables,
404+ &[],
405+ ),
406+ route(
407+ "POST",
408+ "/workspaces/:workspace/actions/variables",
409+ Op::SetActionsVariable,
410+ &[],
411+ ),
412+ route(
413+ "PATCH",
414+ "/workspaces/:workspace/actions/variables/:setting",
415+ Op::SetActionsVariable,
416+ &[],
417+ ),
418+ route(
419+ "DELETE",
420+ "/workspaces/:workspace/actions/variables/:setting",
421+ Op::DeleteActionsVariable,
422+ &[],
423+ ),
268424 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
269425 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
270426 route(
400556 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
401557 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
402558 }
403− for key in ["plan", "id", "workspace", "delivery"] {
559+ for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting"] {
404560 if let Some(value) = param(key) {
405561 input.insert(key.to_owned(), Value::String(value.to_owned()));
406562 }
407563 }
564+ // GitHub says some things with the path alone.
565+ if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
566+ input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
567+ }
568+ if route.path.ends_with("/rerun-failed-jobs") {
569+ input.insert("failed_only".to_owned(), Value::Bool(true));
570+ }
408571 if let Some(number) = param("number") {
409572 // Not a number: zero, which no issue or pull request has.
410573 input.insert(
+2−1
2121 { "binding": "BILLING", "service": "g1t-billing" },
2222 { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
2323 { "binding": "WEBHOOKS", "service": "g1t-webhooks" },
24− { "binding": "AUTOMATIONS", "service": "g1t-automations" }
24+ { "binding": "AUTOMATIONS", "service": "g1t-automations" },
25+ { "binding": "ACTIONS", "service": "g1t-actions" }
2526 ],
2627 "observability": { "enabled": true }
2728 }
+177−0
1+//! Five-field cron schedules, in UTC: minute, hour, day of month, month,
2+//! day of week. Each field takes `*`, a number, a range `a-b`, a list
3+//! `a,b`, and a step `*/n` or `a-b/n`; days of the week also take `mon` to
4+//! `sun`, and months `jan` to `dec`. Shared by automations' schedules and
5+//! workflows' `on.schedule`.
6+
7+#[derive(Clone, Debug, PartialEq, Eq)]
8+pub struct Schedule {
9+ minutes: Vec<bool>,
10+ hours: Vec<bool>,
11+ days: Vec<bool>,
12+ months: Vec<bool>,
13+ weekdays: Vec<bool>,
14+ /// Whether day of month and day of week were each restricted: when both
15+ /// are, either matching is enough, as in every cron.
16+ days_restricted: bool,
17+ weekdays_restricted: bool,
18+}
19+
20+const WEEKDAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
21+/// Months by name, from 1: the empty first entry stands for 0.
22+const MONTHS: [&str; 13] = ["", "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
23+
24+fn field(text: &str, low: u32, high: u32, names: &[&str]) -> Result<(Vec<bool>, bool), String> {
25+ let mut set = vec![false; (high + 1) as usize];
26+ let value = |part: &str| -> Result<u32, String> {
27+ if let Some(at) = names.iter().position(|name| !name.is_empty() && part.eq_ignore_ascii_case(name)) {
28+ return Ok(at as u32);
29+ }
30+ part.parse::<u32>().map_err(|_| format!("`{part}` is not a number"))
31+ };
32+ for item in text.split(',') {
33+ let (range, step) = match item.split_once('/') {
34+ Some((range, step)) => (range, step.parse::<u32>().map_err(|_| format!("`{step}` is not a step"))?),
35+ None => (item, 1),
36+ };
37+ if step == 0 {
38+ return Err("a step cannot be 0".to_owned());
39+ }
40+ let (from, to) = if range == "*" {
41+ (low, high)
42+ } else if let Some((a, b)) = range.split_once('-') {
43+ (value(a)?, value(b)?)
44+ } else {
45+ let at = value(range)?;
46+ (at, if item.contains('/') { high } else { at })
47+ };
48+ // Sunday may be written 7.
49+ let (from, to) = if names.len() == 7 && to == 7 { (from.min(6), 6) } else { (from, to) };
50+ if from < low || to > high || from > to {
51+ return Err(format!("`{item}` is outside {low}-{high}"));
52+ }
53+ let mut at = from;
54+ while at <= to {
55+ set[at as usize] = true;
56+ at += step;
57+ }
58+ if names.len() == 7 && text.split(',').any(|part| part == "7") {
59+ set[0] = true;
60+ }
61+ }
62+ Ok((set, text != "*"))
63+}
64+
65+impl Schedule {
66+ pub fn parse(text: &str) -> Result<Schedule, String> {
67+ let parts: Vec<&str> = text.split_whitespace().collect();
68+ let [minute, hour, day, month, weekday] = parts[..] else {
69+ return Err("a schedule has five fields: minute hour day month weekday, such as `0 9 * * mon`".to_owned());
70+ };
71+ let (minutes, _) = field(minute, 0, 59, &[])?;
72+ let (hours, _) = field(hour, 0, 23, &[])?;
73+ let (days, days_restricted) = field(day, 1, 31, &[])?;
74+ let (months, _) = field(month, 1, 12, &MONTHS)?;
75+ let (weekdays, weekdays_restricted) = field(weekday, 0, 6, &WEEKDAYS)?;
76+ Ok(Schedule {
77+ minutes,
78+ hours,
79+ days,
80+ months,
81+ weekdays,
82+ days_restricted,
83+ weekdays_restricted,
84+ })
85+ }
86+
87+ /// Whether it fires in the minute starting at `ms` since the epoch, UTC.
88+ pub fn fires_at(&self, ms: u64) -> bool {
89+ let minutes_total = ms / 60_000;
90+ let minute = (minutes_total % 60) as usize;
91+ let hour = (minutes_total / 60 % 24) as usize;
92+ let days_since_epoch = (minutes_total / 60 / 24) as i64;
93+ // 1970-01-01 was a Thursday.
94+ let weekday = ((days_since_epoch + 4) % 7) as usize;
95+ let (_, month, day) = civil_from_days(days_since_epoch);
96+ let day_ok = self.days[day as usize];
97+ let weekday_ok = self.weekdays[weekday];
98+ let date_ok = match (self.days_restricted, self.weekdays_restricted) {
99+ (true, true) => day_ok || weekday_ok,
100+ _ => day_ok && weekday_ok,
101+ };
102+ self.minutes[minute] && self.hours[hour] && self.months[month as usize] && date_ok
103+ }
104+}
105+
106+/// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm).
107+fn civil_from_days(days: i64) -> (i64, u32, u32) {
108+ let z = days + 719_468;
109+ let era = z.div_euclid(146_097);
110+ let doe = z.rem_euclid(146_097);
111+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
112+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
113+ let mp = (5 * doy + 2) / 153;
114+ let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
115+ let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
116+ (yoe + era * 400 + i64::from(month <= 2), month, day)
117+}
118+
119+#[cfg(test)]
120+mod tests {
121+ use super::*;
122+
123+ /// Milliseconds at a UTC date and time.
124+ fn at(days_since_epoch: u64, hour: u64, minute: u64) -> u64 {
125+ ((days_since_epoch * 24 + hour) * 60 + minute) * 60_000
126+ }
127+
128+ // 2026-10-05 is a Monday: 20_731 days after 1970-01-01.
129+ const MONDAY: u64 = 20_731;
130+
131+ #[test]
132+ fn dates_are_worked_out() {
133+ assert_eq!(civil_from_days(0), (1970, 1, 1));
134+ assert_eq!(civil_from_days(MONDAY as i64), (2026, 10, 5));
135+ }
136+
137+ #[test]
138+ fn mondays_at_nine() {
139+ let schedule = Schedule::parse("0 9 * * mon").unwrap();
140+ assert!(schedule.fires_at(at(MONDAY, 9, 0)));
141+ assert!(!schedule.fires_at(at(MONDAY, 9, 1)));
142+ assert!(!schedule.fires_at(at(MONDAY + 1, 9, 0)));
143+ assert!(Schedule::parse("0 9 * * 1").unwrap().fires_at(at(MONDAY, 9, 0)));
144+ }
145+
146+ #[test]
147+ fn steps_ranges_and_lists() {
148+ let every_quarter = Schedule::parse("*/15 * * * *").unwrap();
149+ assert!(every_quarter.fires_at(at(MONDAY, 3, 45)));
150+ assert!(!every_quarter.fires_at(at(MONDAY, 3, 44)));
151+ let weekdays = Schedule::parse("30 8-17/3 * * mon-fri").unwrap();
152+ assert!(weekdays.fires_at(at(MONDAY, 14, 30)));
153+ assert!(!weekdays.fires_at(at(MONDAY, 15, 30)));
154+ assert!(!weekdays.fires_at(at(MONDAY + 5, 14, 30)));
155+ let sunday = Schedule::parse("0 0 * * 7").unwrap();
156+ assert!(sunday.fires_at(at(MONDAY + 6, 0, 0)));
157+ // MONDAY is in October.
158+ assert!(Schedule::parse("0 9 * oct mon").unwrap().fires_at(at(MONDAY, 9, 0)));
159+ assert!(!Schedule::parse("0 9 * jan-sep *").unwrap().fires_at(at(MONDAY, 9, 0)));
160+ assert!(Schedule::parse("0 9 * * *").unwrap().fires_at(at(MONDAY, 9, 0)));
161+ }
162+
163+ #[test]
164+ fn day_of_month_or_week_when_both_are_given() {
165+ // The 1st, or any Monday.
166+ let schedule = Schedule::parse("0 0 1 * mon").unwrap();
167+ assert!(schedule.fires_at(at(MONDAY, 0, 0)));
168+ assert!(!schedule.fires_at(at(MONDAY + 1, 0, 0)));
169+ }
170+
171+ #[test]
172+ fn nonsense_is_refused() {
173+ for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] {
174+ assert!(Schedule::parse(text).is_err(), "{text}");
175+ }
176+ }
177+}
+1−0
88 //! (in a container) share it: the service decides what runs, the sandbox
99 //! runs the steps, and both read workflows and expressions the same way.
1010
11+pub mod cron;
1112 pub mod events;
1213 pub mod expr;
1314 pub mod filter;
+2−2
400400 ),
401401 _ => (None, true, None),
402402 };
403− if uses.as_deref().is_some_and(|uses| !uses.starts_with("./")) {
404− note(Severity::Unsupported, Some(id), "Reusable workflows from other repositories are not called on g1t yet; ones in this repository (`./.github/workflows/…`) are.".to_owned());
403+ if uses.is_some() {
404+ note(Severity::Unsupported, Some(id), "Reusable workflows (`uses:` on a job) are not called on g1t yet, so this job fails.".to_owned());
405405 }
406406 jobs.push(Job {
407407 id: id.clone(),
+318−0
1+//! The actions service: GitHub Actions workflows, run on g1t as they are.
2+//!
3+//! A repository's `.github/workflows/*.yml` are read from the commit an
4+//! event is about (the default branch for issues, schedules and manual
5+//! runs). Each workflow an event starts becomes a run; each job of the run
6+//! (one per matrix combination) runs in a sandbox once the jobs it needs
7+//! have finished. Jobs report their steps and logs back as they go, and a
8+//! run on a pull request's head is a status on that pull request.
9+//!
10+//! Secrets and variables belong to a repository or to its workspace; a
11+//! repository's override its workspace's of the same name. Secret values
12+//! are sealed at rest and never returned.
13+//!
14+//! Mirrors `packages/contracts/src/actions.ts`.
15+
16+use serde::{Deserialize, Serialize};
17+use serde_json::Value;
18+
19+use crate::repos::RepoPath;
20+use crate::{User, Viewer};
21+
22+/// A note on something in a workflow that runs differently on g1t.
23+#[derive(Clone, Debug, Serialize, Deserialize)]
24+#[serde(rename_all = "camelCase")]
25+pub struct WorkflowNote {
26+ /// `info`, `warning` or `unsupported`.
27+ pub severity: String,
28+ pub job: Option<String>,
29+ pub message: String,
30+}
31+
32+#[derive(Clone, Debug, Serialize, Deserialize)]
33+#[serde(rename_all = "camelCase")]
34+pub struct Workflow {
35+ pub id: String,
36+ /// `.github/workflows/ci.yml`.
37+ pub path: String,
38+ pub name: String,
39+ /// The events that start it, such as `push` and `pull_request`.
40+ pub events: Vec<String>,
41+ /// `active`, or `disabled` when a member turned it off.
42+ pub state: String,
43+ /// Why the file cannot be used, if it cannot.
44+ pub error: Option<String>,
45+ pub notes: Vec<WorkflowNote>,
46+ /// `on.workflow_dispatch.inputs` as written, when it can be run by hand.
47+ pub dispatch: Option<Value>,
48+ pub last_run: Option<WorkflowRun>,
49+}
50+
51+#[derive(Clone, Debug, Serialize, Deserialize)]
52+#[serde(rename_all = "camelCase")]
53+pub struct WorkflowRun {
54+ pub id: String,
55+ pub workflow_id: String,
56+ pub path: String,
57+ /// The workflow's name.
58+ pub name: String,
59+ /// `run-name`, or what started it: a commit's subject, a pull request's title.
60+ pub title: String,
61+ /// Counts the workflow's runs: 1, 2, 3…
62+ pub number: u64,
63+ pub attempt: u64,
64+ /// The GitHub event: `push`, `pull_request`, `schedule`…
65+ pub event: String,
66+ #[serde(rename = "ref")]
67+ pub git_ref: String,
68+ pub sha: String,
69+ /// The pull request it ran for, if any.
70+ pub pull: Option<u32>,
71+ /// `queued`, `in_progress` or `completed`.
72+ pub status: String,
73+ /// When completed: `success`, `failure`, `cancelled` or `skipped`.
74+ pub conclusion: Option<String>,
75+ /// Why it could not start, such as a workflow file that does not read.
76+ pub error: Option<String>,
77+ /// Username of whoever caused it.
78+ pub actor: Option<String>,
79+ pub created_at: String,
80+ pub started_at: Option<String>,
81+ pub finished_at: Option<String>,
82+}
83+
84+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
85+#[serde(rename_all = "camelCase")]
86+pub struct StepState {
87+ /// From 1.
88+ pub number: u32,
89+ pub name: String,
90+ /// `queued`, `in_progress` or `completed`.
91+ pub status: String,
92+ /// `success`, `failure`, `cancelled` or `skipped`.
93+ pub conclusion: Option<String>,
94+ pub started_at: Option<String>,
95+ pub finished_at: Option<String>,
96+}
97+
98+/// A message a step left with `::error::`, `::warning::` or `::notice::`.
99+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
100+#[serde(rename_all = "camelCase")]
101+pub struct Annotation {
102+ /// `error`, `warning` or `notice`.
103+ pub level: String,
104+ pub message: String,
105+ pub title: Option<String>,
106+ pub file: Option<String>,
107+ pub line: Option<u32>,
108+}
109+
110+#[derive(Clone, Debug, Serialize, Deserialize)]
111+#[serde(rename_all = "camelCase")]
112+pub struct Job {
113+ pub id: String,
114+ pub run_id: String,
115+ /// Its key under `jobs:`.
116+ pub key: String,
117+ /// With its matrix combination: `test (ubuntu-latest, 20)`.
118+ pub name: String,
119+ pub needs: Vec<String>,
120+ /// `queued`, `waiting` (for the jobs it needs), `in_progress` or `completed`.
121+ pub status: String,
122+ pub conclusion: Option<String>,
123+ pub steps: Vec<StepState>,
124+ pub annotations: Vec<Annotation>,
125+ /// Why it did not run, or what stopped it.
126+ pub reason: Option<String>,
127+ pub started_at: Option<String>,
128+ pub finished_at: Option<String>,
129+}
130+
131+#[derive(Clone, Debug, Serialize, Deserialize)]
132+#[serde(rename_all = "camelCase")]
133+pub struct RunDetail {
134+ pub run: WorkflowRun,
135+ pub jobs: Vec<Job>,
136+ /// The workflow's notes, as of the run's commit.
137+ pub notes: Vec<WorkflowNote>,
138+}
139+
140+#[derive(Clone, Debug, Serialize, Deserialize)]
141+#[serde(rename_all = "camelCase")]
142+pub struct LogChunk {
143+ pub seq: u64,
144+ /// The step it belongs to, from 1; 0 for the job's setup.
145+ pub step: u32,
146+ pub text: String,
147+}
148+
149+#[derive(Clone, Debug, Serialize, Deserialize)]
150+#[serde(rename_all = "camelCase")]
151+pub struct JobLog {
152+ pub chunks: Vec<LogChunk>,
153+ /// Whether the job has finished, so no more will come.
154+ pub done: bool,
155+}
156+
157+/// A secret's or variable's name, and for a variable its value.
158+#[derive(Clone, Debug, Serialize, Deserialize)]
159+#[serde(rename_all = "camelCase")]
160+pub struct Setting {
161+ pub name: String,
162+ /// Variables only; secrets are never returned.
163+ pub value: Option<String>,
164+ /// `repository` or `workspace`.
165+ pub scope: String,
166+ pub updated_at: String,
167+}
168+
169+// --- Methods ---------------------------------------------------------------
170+
171+/// `workflows`. Returns `Outcome<Vec<Workflow>>`.
172+#[derive(Debug, Serialize, Deserialize)]
173+pub struct WorkflowsArgs {
174+ pub repo: RepoPath,
175+ pub viewer: Viewer,
176+}
177+
178+/// `runs`: newest first. Returns `Outcome<Vec<WorkflowRun>>`.
179+#[derive(Debug, Serialize, Deserialize)]
180+pub struct RunsArgs {
181+ pub repo: RepoPath,
182+ pub viewer: Viewer,
183+ /// A workflow's id or file name.
184+ #[serde(default)]
185+ pub workflow: Option<String>,
186+ #[serde(default)]
187+ pub branch: Option<String>,
188+ #[serde(default)]
189+ pub event: Option<String>,
190+ /// The pull request's number.
191+ #[serde(default)]
192+ pub pull: Option<u32>,
193+ #[serde(default)]
194+ pub sha: Option<String>,
195+ #[serde(default)]
196+ pub limit: Option<u32>,
197+}
198+
199+/// `run`. Returns `Outcome<RunDetail>`.
200+#[derive(Debug, Serialize, Deserialize)]
201+pub struct RunArgs {
202+ pub repo: RepoPath,
203+ pub viewer: Viewer,
204+ pub id: String,
205+}
206+
207+/// `logs`: a job's log after `after`. Returns `Outcome<JobLog>`.
208+#[derive(Debug, Serialize, Deserialize)]
209+pub struct LogsArgs {
210+ pub repo: RepoPath,
211+ pub viewer: Viewer,
212+ pub job: String,
213+ #[serde(default)]
214+ pub after: u64,
215+}
216+
217+/// `dispatch`: run a workflow that has `workflow_dispatch`. Members only.
218+/// Returns `Outcome<WorkflowRun>`.
219+#[derive(Debug, Serialize, Deserialize)]
220+pub struct DispatchArgs {
221+ pub actor: User,
222+ pub repo: RepoPath,
223+ /// A workflow's id or file name.
224+ pub workflow: String,
225+ /// A branch or tag; the default branch when absent.
226+ #[serde(default, rename = "ref")]
227+ pub git_ref: Option<String>,
228+ #[serde(default)]
229+ pub inputs: serde_json::Map<String, Value>,
230+}
231+
232+/// `cancel` and `rerun` (all jobs, or with `failed_only` the ones that did
233+/// not succeed). Members only. Returns `Outcome<WorkflowRun>`.
234+#[derive(Debug, Serialize, Deserialize)]
235+pub struct RunActionArgs {
236+ pub actor: User,
237+ pub repo: RepoPath,
238+ pub id: String,
239+ #[serde(default)]
240+ pub failed_only: bool,
241+}
242+
243+/// `set_workflow_enabled`. Members only. Returns `Outcome<Workflow>`.
244+#[derive(Debug, Serialize, Deserialize)]
245+pub struct SetWorkflowEnabledArgs {
246+ pub actor: User,
247+ pub repo: RepoPath,
248+ pub workflow: String,
249+ pub enabled: bool,
250+}
251+
252+/// Whose secrets or variables: a repository's, or with only `workspace`,
253+/// a workspace's.
254+#[derive(Clone, Debug, Serialize, Deserialize)]
255+pub struct SettingsOwner {
256+ #[serde(default)]
257+ pub repo: Option<RepoPath>,
258+ #[serde(default)]
259+ pub workspace: Option<String>,
260+}
261+
262+/// `settings`: the secrets (`kind: secret`) or variables (`kind: variable`)
263+/// of a repository, with its workspace's, or of a workspace. Members only.
264+/// Returns `Outcome<Vec<Setting>>`.
265+#[derive(Debug, Serialize, Deserialize)]
266+pub struct SettingsArgs {
267+ pub actor: User,
268+ #[serde(flatten)]
269+ pub owner: SettingsOwner,
270+ pub kind: String,
271+}
272+
273+/// `set_setting`: add or replace one. A repository's need a member; a
274+/// workspace's an owner. Returns `Outcome<Setting>`.
275+#[derive(Debug, Serialize, Deserialize)]
276+pub struct SetSettingArgs {
277+ pub actor: User,
278+ #[serde(flatten)]
279+ pub owner: SettingsOwner,
280+ pub kind: String,
281+ pub name: String,
282+ pub value: String,
283+}
284+
285+/// `delete_setting`. Returns `Outcome<bool>`.
286+#[derive(Debug, Serialize, Deserialize)]
287+pub struct DeleteSettingArgs {
288+ pub actor: User,
289+ #[serde(flatten)]
290+ pub owner: SettingsOwner,
291+ pub kind: String,
292+ pub name: String,
293+}
294+
295+/// `job_spec` and `job_report`: the sandbox running a job, with the job's
296+/// own token. `report` is one of:
297+/// `{"kind": "step", "number", "status", "conclusion"}`,
298+/// `{"kind": "log", "step", "text"}`,
299+/// `{"kind": "annotation", "level", "message", "title", "file", "line"}`,
300+/// `{"kind": "done", "conclusion", "outputs", "reason"}`.
301+#[derive(Debug, Serialize, Deserialize)]
302+pub struct JobCallArgs {
303+ pub job: String,
304+ pub token: String,
305+ #[serde(default)]
306+ pub report: Value,
307+}
308+
309+/// What the runner needs to start a job's sandbox.
310+#[derive(Debug, Serialize, Deserialize)]
311+#[serde(rename_all = "camelCase")]
312+pub struct StartJobArgs {
313+ pub job: String,
314+ pub token: String,
315+ pub repo: RepoPath,
316+ /// Minutes before the job is stopped.
317+ pub timeout_minutes: u32,
318+}
+1−0
44 //! arguments of each of its methods. Services and their callers depend on
55 //! this crate, never on each other's code.
66
7+pub mod actions;
78 pub mod automations;
89 pub mod billing;
910 pub mod events;
+31−0
415415 /// Messages people sent the agent while it worked, oldest first.
416416 #[serde(default)]
417417 pub messages: Vec<AgentMessage>,
418+ /// What workflow runs said about its head commit, one per workflow.
419+ #[serde(default)]
420+ pub statuses: Vec<CommitStatus>,
421+}
422+
423+/// What a workflow run (or another tool) says about a commit.
424+#[derive(Clone, Debug, Serialize, Deserialize)]
425+#[serde(rename_all = "camelCase")]
426+pub struct CommitStatus {
427+ /// What reported it, such as `CI / push`.
428+ pub context: String,
429+ /// `pending`, `success`, `failure` or `error`.
430+ pub state: String,
431+ pub description: Option<String>,
432+ /// Where to see more, such as the run's page.
433+ pub target_url: Option<String>,
434+ pub updated_at: String,
435+}
436+
437+/// `set_commit_status`: for services only. Returns `Outcome<bool>`.
438+#[derive(Debug, Serialize, Deserialize)]
439+#[serde(rename_all = "camelCase")]
440+pub struct SetCommitStatusArgs {
441+ pub repo_id: String,
442+ pub sha: String,
443+ pub context: String,
444+ pub state: String,
445+ #[serde(default)]
446+ pub description: Option<String>,
447+ #[serde(default)]
448+ pub target_url: Option<String>,
418449 }
419450
420451 /// A message a person sent an agent at work on a pull request. The agent
+5−1
33 version = "0.1.0"
44 edition.workspace = true
55 license.workspace = true
6−description = "The program inside a g1t sandbox: runs an agent, checks, a catch-up merge or a review, and reports back."
6+description = "The program inside a g1t sandbox: runs an agent, checks, a catch-up merge, a review or a GitHub Actions job, and reports back."
77
88 [dependencies]
9+g1t-actions = { path = "../actions" }
10+sha2 = "0.10"
11+serde_yaml = "0.9"
12+hex = "0.4"
913 anyhow = "1"
1014 base64 = "0.22"
1115 serde = { workspace = true }
+164−0
1+//! The files a step writes to talk back (`GITHUB_OUTPUT`, `GITHUB_ENV`,
2+//! `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY`), and `hashFiles`.
3+
4+use std::collections::BTreeMap;
5+use std::io::Read;
6+use std::path::{Path, PathBuf};
7+
8+use g1t_actions::filter::Patterns;
9+use sha2::{Digest, Sha256};
10+
11+/// `name=value` lines and `name<<DELIMITER` … `DELIMITER` blocks.
12+pub(crate) fn key_values(text: &str) -> Result<BTreeMap<String, String>, String> {
13+ let mut out = BTreeMap::new();
14+ let mut lines = text.lines();
15+ while let Some(line) = lines.next() {
16+ if line.trim().is_empty() {
17+ continue;
18+ }
19+ let heredoc = line.find("<<");
20+ let equals = line.find('=');
21+ match (heredoc, equals) {
22+ (Some(at), eq) if eq.is_none_or(|eq| at < eq) => {
23+ let name = line[..at].to_owned();
24+ let delimiter = &line[at + 2..];
25+ if name.is_empty() || delimiter.is_empty() {
26+ return Err(format!("`{line}` is not a name and a delimiter."));
27+ }
28+ let mut value = Vec::new();
29+ let mut closed = false;
30+ for body in lines.by_ref() {
31+ if body == delimiter {
32+ closed = true;
33+ break;
34+ }
35+ value.push(body);
36+ }
37+ if !closed {
38+ return Err(format!("The value of `{name}` never reaches its delimiter `{delimiter}`."));
39+ }
40+ out.insert(name, value.join("\n"));
41+ }
42+ (_, Some(eq)) => {
43+ out.insert(line[..eq].to_owned(), line[eq + 1..].to_owned());
44+ }
45+ _ => return Err(format!("`{line}` is not `name=value`.")),
46+ }
47+ }
48+ Ok(out)
49+}
50+
51+/// The files of one step, made empty before it runs.
52+pub(crate) struct StepFiles {
53+ pub(crate) output: PathBuf,
54+ pub(crate) env: PathBuf,
55+ pub(crate) path: PathBuf,
56+ pub(crate) state: PathBuf,
57+ pub(crate) summary: PathBuf,
58+}
59+
60+impl StepFiles {
61+ pub(crate) fn new(temp: &Path, id: &str) -> std::io::Result<StepFiles> {
62+ let dir = temp.join("_runner_file_commands");
63+ std::fs::create_dir_all(&dir)?;
64+ let files = StepFiles {
65+ output: dir.join(format!("set_output_{id}")),
66+ env: dir.join(format!("set_env_{id}")),
67+ path: dir.join(format!("add_path_{id}")),
68+ state: dir.join(format!("save_state_{id}")),
69+ summary: dir.join(format!("step_summary_{id}")),
70+ };
71+ for file in [&files.output, &files.env, &files.path, &files.state, &files.summary] {
72+ std::fs::write(file, "")?;
73+ }
74+ Ok(files)
75+ }
76+
77+ pub(crate) fn read(path: &Path) -> String {
78+ let mut text = String::new();
79+ if let Ok(mut file) = std::fs::File::open(path) {
80+ let _ = file.read_to_string(&mut text);
81+ }
82+ text
83+ }
84+
85+ pub(crate) fn variables(&self) -> [(&'static str, String); 5] {
86+ [
87+ ("GITHUB_OUTPUT", self.output.display().to_string()),
88+ ("GITHUB_ENV", self.env.display().to_string()),
89+ ("GITHUB_PATH", self.path.display().to_string()),
90+ ("GITHUB_STATE", self.state.display().to_string()),
91+ ("GITHUB_STEP_SUMMARY", self.summary.display().to_string()),
92+ ]
93+ }
94+}
95+
96+fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
97+ let Ok(entries) = std::fs::read_dir(dir) else { return };
98+ for entry in entries.flatten() {
99+ let path = entry.path();
100+ let Ok(kind) = entry.file_type() else { continue };
101+ if kind.is_dir() {
102+ if entry.file_name() == ".git" {
103+ continue;
104+ }
105+ walk(root, &path, out);
106+ } else if kind.is_file()
107+ && let Ok(relative) = path.strip_prefix(root)
108+ {
109+ out.push(relative.to_string_lossy().replace('\\', "/"));
110+ }
111+ }
112+}
113+
114+/// `hashFiles(patterns)`: the SHA-256 of the SHA-256 of each matching file
115+/// in the workspace, in path order; empty when nothing matches.
116+pub(crate) fn hash_files(workspace: &Path, patterns: &[String]) -> String {
117+ // Patterns may be absolute under the workspace, or relative to it.
118+ let prefix = format!("{}/", workspace.display());
119+ let relative: Vec<String> = patterns.iter().map(|p| p.strip_prefix(&prefix).unwrap_or(p).to_owned()).collect();
120+ let patterns = Patterns::new(&relative);
121+ let mut files = Vec::new();
122+ walk(workspace, workspace, &mut files);
123+ files.sort();
124+ let mut all = Sha256::new();
125+ let mut any = false;
126+ for file in files.iter().filter(|file| patterns.includes(file)) {
127+ let Ok(bytes) = std::fs::read(workspace.join(file)) else { continue };
128+ all.update(Sha256::digest(&bytes));
129+ any = true;
130+ }
131+ if any { hex::encode(all.finalize()) } else { String::new() }
132+}
133+
134+#[cfg(test)]
135+mod tests {
136+ use super::*;
137+
138+ #[test]
139+ fn values_and_heredocs() {
140+ let text = "version=1.2.3\nnotes<<EOF\nline one\nline=two\nEOF\nempty=\n";
141+ let values = key_values(text).unwrap();
142+ assert_eq!(values["version"], "1.2.3");
143+ assert_eq!(values["notes"], "line one\nline=two");
144+ assert_eq!(values["empty"], "");
145+ assert!(key_values("x<<EOF\nnever closed").is_err());
146+ assert!(key_values("no equals").is_err());
147+ // A value holding `<<` after its `=` is a plain value.
148+ assert_eq!(key_values("cmd=a << b").unwrap()["cmd"], "a << b");
149+ }
150+
151+ #[test]
152+ fn hashes_files_by_pattern() {
153+ let dir = std::env::temp_dir().join(format!("g1t-hash-{}", std::process::id()));
154+ std::fs::create_dir_all(dir.join("a")).unwrap();
155+ std::fs::write(dir.join("a/package-lock.json"), "{}").unwrap();
156+ std::fs::write(dir.join("README.md"), "hi").unwrap();
157+ let one = hash_files(&dir, &["**/package-lock.json".to_owned()]);
158+ assert_eq!(one.len(), 64);
159+ assert_eq!(one, hash_files(&dir, &["**/package-lock.json".to_owned()]));
160+ assert_ne!(one, hash_files(&dir, &["**/*".to_owned()]));
161+ assert_eq!(hash_files(&dir, &["**/Cargo.lock".to_owned()]), "");
162+ let _ = std::fs::remove_dir_all(&dir);
163+ }
164+}
+588−0
1+//! Runs one GitHub Actions job, as GitHub's runner would: its steps in
2+//! order, each `run` in a shell and each `uses` as the action it names,
3+//! with the `${{ }}` contexts, the `GITHUB_*` variables and files, and the
4+//! workflow commands steps print. It reports every step and the log to
5+//! g1t as it goes.
6+//!
7+//! Configuration comes from the environment: `G1T_API`, and `ACTIONS_JOB`
8+//! and `ACTIONS_TOKEN`, the job and its own token. Everything else, the
9+//! job's definition, its contexts and its secrets, is fetched with them.
10+
11+mod files;
12+mod process;
13+mod report;
14+mod uses;
15+
16+use std::collections::BTreeMap;
17+use std::path::{Path, PathBuf};
18+use std::process::Command;
19+use std::time::{Duration, Instant};
20+
21+use anyhow::{Context, Result};
22+use g1t_actions::events::WORKSPACE;
23+use g1t_actions::expr::{self, Scope, Status};
24+use serde_json::{Map, Value, json};
25+
26+use files::StepFiles;
27+use process::{Commands, Ended};
28+use report::{Api, Log};
29+
30+const TEMP: &str = "/home/runner/_temp";
31+
32+/// Who is running steps: the job itself, or a composite action inside it.
33+#[derive(Clone, Default)]
34+pub(crate) struct Frame {
35+ /// The `steps` context.
36+ pub(crate) steps: Map<String, Value>,
37+ /// A composite action's `inputs`, in place of the workflow's.
38+ pub(crate) inputs: Option<Value>,
39+ /// A composite action's folder, for `github.action_path`.
40+ pub(crate) action_path: Option<String>,
41+ /// Variables a composite action's caller set for its steps.
42+ pub(crate) env: BTreeMap<String, String>,
43+}
44+
45+/// An action's `post` step, run when the job's steps are done.
46+pub(crate) struct Post {
47+ pub(crate) name: String,
48+ pub(crate) action_dir: PathBuf,
49+ pub(crate) script: String,
50+ pub(crate) condition: String,
51+ pub(crate) env: BTreeMap<String, String>,
52+}
53+
54+pub(crate) struct Job {
55+ pub(crate) log: Log,
56+ pub(crate) spec: Value,
57+ pub(crate) workspace: PathBuf,
58+ pub(crate) temp: PathBuf,
59+ /// This process's own variables, less its credentials, and GitHub's.
60+ base_env: BTreeMap<String, String>,
61+ /// Written to `GITHUB_ENV` by earlier steps.
62+ added_env: BTreeMap<String, String>,
63+ /// Written to `GITHUB_PATH` by earlier steps, newest first.
64+ path_prepend: Vec<String>,
65+ workflow_env: BTreeMap<String, String>,
66+ job_env: BTreeMap<String, String>,
67+ /// github, vars, secrets, inputs, matrix, needs, strategy, runner.
68+ pub(crate) contexts: Map<String, Value>,
69+ pub(crate) failed: bool,
70+ pub(crate) posts: Vec<Post>,
71+ step_names: Vec<String>,
72+ deadline: Instant,
73+ debug: bool,
74+ /// What the last Node process left, for the step that ran it.
75+ pub(crate) last_node_outputs: BTreeMap<String, String>,
76+ pub(crate) last_node_state: BTreeMap<String, String>,
77+}
78+
79+fn text_map(value: Option<&Value>) -> BTreeMap<String, String> {
80+ value
81+ .and_then(Value::as_object)
82+ .map(|map| map.iter().map(|(k, v)| (k.clone(), expr::to_text(v))).collect())
83+ .unwrap_or_default()
84+}
85+
86+/// A step's title when it has no name, as GitHub shows it.
87+fn default_title(step: &Map<String, Value>) -> String {
88+ if let Some(uses) = step.get("uses").and_then(Value::as_str) {
89+ return format!("Run {uses}");
90+ }
91+ let run = step.get("run").map(expr::to_text).unwrap_or_default();
92+ let first = run.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().trim();
93+ format!("Run {first}")
94+}
95+
96+impl Job {
97+ fn status(&self) -> Status {
98+ if self.failed { Status::Failure } else { Status::Success }
99+ }
100+
101+ /// The contexts an expression in a step can use.
102+ pub(crate) fn contexts_for(&self, frame: &Frame, env: &BTreeMap<String, String>) -> Map<String, Value> {
103+ let mut contexts = self.contexts.clone();
104+ contexts.insert("env".into(), Value::Object(env.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect()));
105+ contexts.insert("steps".into(), Value::Object(frame.steps.clone()));
106+ contexts.insert("job".into(), json!({ "status": if self.failed { "failure" } else { "success" } }));
107+ if let Some(inputs) = &frame.inputs {
108+ contexts.insert("inputs".into(), inputs.clone());
109+ }
110+ if let Some(path) = &frame.action_path
111+ && let Some(github) = contexts.get_mut("github")
112+ {
113+ github["action_path"] = Value::String(path.clone());
114+ }
115+ contexts
116+ }
117+
118+ /// Runs `f` with a scope over these contexts.
119+ pub(crate) fn with_scope<T>(&self, contexts: &Map<String, Value>, f: impl FnOnce(&Scope) -> T) -> T {
120+ let workspace = self.workspace.clone();
121+ let hash = move |patterns: &[String]| files::hash_files(&workspace, patterns);
122+ let scope = Scope {
123+ contexts,
124+ status: self.status(),
125+ hash_files: Some(&hash),
126+ };
127+ f(&scope)
128+ }
129+
130+ /// The `env` context for a step: the workflow's, the job's, what earlier
131+ /// steps wrote to `GITHUB_ENV`, and the frame's.
132+ fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> {
133+ let mut env = self.added_env.clone();
134+ env.extend(self.workflow_env.clone());
135+ env.extend(self.job_env.clone());
136+ env.extend(frame.env.clone());
137+ env
138+ }
139+
140+ /// What a process for a step is given.
141+ pub(crate) fn process_env(&self, env: &BTreeMap<String, String>, files: &StepFiles) -> BTreeMap<String, String> {
142+ let mut out = self.base_env.clone();
143+ out.extend(env.clone());
144+ for (name, value) in files.variables() {
145+ out.insert(name.to_owned(), value);
146+ }
147+ if !self.path_prepend.is_empty() {
148+ let current = out.get("PATH").cloned().unwrap_or_default();
149+ out.insert("PATH".into(), format!("{}:{current}", self.path_prepend.join(":")));
150+ }
151+ out
152+ }
153+
154+ /// Takes in what a step wrote to its files. Returns its outputs.
155+ pub(crate) fn absorb(&mut self, files: &StepFiles, commands: &Commands) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
156+ let mut outputs: BTreeMap<String, String> = commands.outputs.clone();
157+ match files::key_values(&StepFiles::read(&files.output)) {
158+ Ok(values) => outputs.extend(values),
159+ Err(problem) => self.log.line(&format!("##[error]$GITHUB_OUTPUT: {problem}")),
160+ }
161+ match files::key_values(&StepFiles::read(&files.env)) {
162+ Ok(values) => {
163+ for (name, value) in values {
164+ if name.starts_with("GITHUB_") || name == "NODE_OPTIONS" {
165+ self.log.line(&format!("##[warning]{name} cannot be set through $GITHUB_ENV."));
166+ continue;
167+ }
168+ self.added_env.insert(name, value);
169+ }
170+ }
171+ Err(problem) => self.log.line(&format!("##[error]$GITHUB_ENV: {problem}")),
172+ }
173+ for line in StepFiles::read(&files.path).lines().map(str::trim).filter(|l| !l.is_empty()) {
174+ self.path_prepend.insert(0, line.to_owned());
175+ }
176+ let mut state = commands.state.clone();
177+ if let Ok(values) = files::key_values(&StepFiles::read(&files.state)) {
178+ state.extend(values);
179+ }
180+ let summary = StepFiles::read(&files.summary);
181+ if !summary.trim().is_empty() {
182+ self.log.line("##[group]Step summary");
183+ for line in summary.lines() {
184+ self.log.line(line);
185+ }
186+ self.log.line("##[endgroup]");
187+ }
188+ (outputs, state)
189+ }
190+
191+ pub(crate) fn remaining_time(&self) -> Duration {
192+ self.remaining()
193+ }
194+
195+ fn remaining(&self) -> Duration {
196+ self.deadline.saturating_duration_since(Instant::now())
197+ }
198+
199+ /// Runs a shell script for a `run` step.
200+ pub(crate) fn run_script(
201+ &mut self,
202+ script: &str,
203+ shell: Option<&str>,
204+ working_directory: Option<&str>,
205+ env: &BTreeMap<String, String>,
206+ timeout: Duration,
207+ ) -> (bool, BTreeMap<String, String>, BTreeMap<String, String>) {
208+ let id = format!("{:x}", rand_id());
209+ let shell = shell.map(str::trim).filter(|s| !s.is_empty());
210+ let (program, args, extension): (String, Vec<String>, &str) = match shell {
211+ None => ("bash".into(), vec!["-e".into(), "{0}".into()], "sh"),
212+ Some("bash") => ("bash".into(), vec!["--noprofile".into(), "--norc".into(), "-eo".into(), "pipefail".into(), "{0}".into()], "sh"),
213+ Some("sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
214+ Some("python") => ("python3".into(), vec!["{0}".into()], "py"),
215+ Some(other @ ("pwsh" | "powershell" | "cmd")) => {
216+ self.log.line(&format!("##[error]`shell: {other}` needs Windows or PowerShell, which g1t's Linux runners do not have."));
217+ return (false, BTreeMap::new(), BTreeMap::new());
218+ }
219+ Some(custom) => {
220+ let mut parts = custom.split_whitespace().map(str::to_owned);
221+ let program = parts.next().unwrap_or_default();
222+ let mut args: Vec<String> = parts.collect();
223+ if !args.iter().any(|a| a.contains("{0}")) {
224+ args.push("{0}".into());
225+ }
226+ (program, args, "sh")
227+ }
228+ };
229+ let script_path = self.temp.join(format!("{id}.{extension}"));
230+ if let Err(error) = std::fs::write(&script_path, script) {
231+ self.log.line(&format!("##[error]Could not write the script: {error}"));
232+ return (false, BTreeMap::new(), BTreeMap::new());
233+ }
234+ let files = match StepFiles::new(&self.temp, &id) {
235+ Ok(files) => files,
236+ Err(error) => {
237+ self.log.line(&format!("##[error]Could not make the step's files: {error}"));
238+ return (false, BTreeMap::new(), BTreeMap::new());
239+ }
240+ };
241+ let args: Vec<String> = args.iter().map(|a| a.replace("{0}", &script_path.display().to_string())).collect();
242+ self.log.line(&format!("shell: {program} {}", args.join(" ")));
243+ let dir = match working_directory {
244+ Some(dir) if Path::new(dir).is_absolute() => PathBuf::from(dir),
245+ Some(dir) => self.workspace.join(dir),
246+ None => self.workspace.clone(),
247+ };
248+ let mut command = Command::new(&program);
249+ command.args(&args).current_dir(&dir).env_clear().envs(self.process_env(env, &files));
250+ let mut commands = Commands {
251+ debug: self.debug,
252+ ..Commands::default()
253+ };
254+ let ended = process::run(command, timeout.min(self.remaining()), &mut self.log, &mut commands);
255+ let ok = match ended {
256+ Ok(Ended::Exited(0)) => true,
257+ Ok(Ended::Exited(code)) => {
258+ self.log.line(&format!("##[error]Process completed with exit code {code}."));
259+ false
260+ }
261+ Ok(Ended::TimedOut) => {
262+ self.log.line("##[error]The step ran past its time limit and was stopped.");
263+ false
264+ }
265+ Err(error) => {
266+ self.log.line(&format!("##[error]{program} could not be started: {error}"));
267+ false
268+ }
269+ };
270+ let (outputs, state) = self.absorb(&files, &commands);
271+ (ok, outputs, state)
272+ }
273+
274+ /// Runs one step of a frame. Returns whether it succeeded (its
275+ /// conclusion). `number` is the step the log belongs to.
276+ pub(crate) fn step(&mut self, frame: &mut Frame, step: &Map<String, Value>, number: u32, report: bool, defaults: &Map<String, Value>) -> bool {
277+ let env_before = self.env_context(frame);
278+ let contexts = self.contexts_for(frame, &env_before);
279+ let title = match step.get("name").map(expr::to_text) {
280+ Some(name) => self.with_scope(&contexts, |scope| expr::interpolate(&name, scope)).unwrap_or(name),
281+ None => default_title(step),
282+ };
283+ let condition = step.get("if").map(expr::to_text).unwrap_or_default();
284+ let run_it = match self.with_scope(&contexts, |scope| expr::condition(&condition, scope)) {
285+ Ok(run_it) => run_it,
286+ Err(problem) => {
287+ self.log.line(&format!("##[error]The step's `if` does not read: {problem}"));
288+ self.failed = true;
289+ if report {
290+ self.log.step_state(number, &title, "completed", Some("failure"));
291+ }
292+ return false;
293+ }
294+ };
295+ let id = step.get("id").map(expr::to_text);
296+ if !run_it {
297+ if let Some(id) = &id {
298+ frame.steps.insert(id.clone(), json!({ "outputs": {}, "outcome": "skipped", "conclusion": "skipped" }));
299+ }
300+ if report {
301+ self.log.step_state(number, &title, "completed", Some("skipped"));
302+ }
303+ return true;
304+ }
305+ if report {
306+ self.log.step(number);
307+ self.log.step_state(number, &title, "in_progress", None);
308+ }
309+
310+ // The step's own env, read with the contexts before it.
311+ let mut env = env_before.clone();
312+ if let Some(Value::Object(step_env)) = step.get("env") {
313+ for (name, value) in step_env {
314+ let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
315+ env.insert(name.clone(), expr::to_text(&value));
316+ }
317+ }
318+ let timeout = step
319+ .get("timeout-minutes")
320+ .and_then(|v| self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).ok())
321+ .and_then(|v| v.as_f64().or_else(|| expr::to_text(&v).parse().ok()))
322+ .map_or(Duration::from_secs(6 * 3600), |minutes| Duration::from_secs_f64(minutes * 60.0));
323+ let continue_on_error = step
324+ .get("continue-on-error")
325+ .and_then(|v| self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).ok())
326+ .is_some_and(|v| expr::truthy(&v));
327+
328+ let (ok, outputs) = if let Some(run) = step.get("run").map(expr::to_text) {
329+ let script = match self.with_scope(&contexts, |scope| expr::interpolate(&run, scope)) {
330+ Ok(script) => script,
331+ Err(problem) => {
332+ self.log.line(&format!("##[error]The script does not read: {problem}"));
333+ String::new()
334+ }
335+ };
336+ self.log.line(&format!("##[group]{title}"));
337+ for line in script.lines() {
338+ self.log.line(line);
339+ }
340+ self.log.line("##[endgroup]");
341+ let shell = step
342+ .get("shell")
343+ .map(expr::to_text)
344+ .or_else(|| defaults.get("shell").map(expr::to_text));
345+ if frame.action_path.is_some() && shell.is_none() {
346+ self.log.line("##[error]A composite action's `run` steps need a `shell`.");
347+ (false, BTreeMap::new())
348+ } else {
349+ let working_directory = step
350+ .get("working-directory")
351+ .or_else(|| defaults.get("working-directory"))
352+ .map(|v| self.with_scope(&contexts, |scope| expr::interpolate(&expr::to_text(v), scope)).unwrap_or_else(|_| expr::to_text(v)));
353+ let mut env = env;
354+ if let Some(path) = &frame.action_path {
355+ env.insert("GITHUB_ACTION_PATH".into(), path.clone());
356+ }
357+ let (ok, outputs, _) = self.run_script(&script, shell.as_deref(), working_directory.as_deref(), &env, timeout);
358+ (ok, outputs)
359+ }
360+ } else if let Some(uses) = step.get("uses").map(expr::to_text) {
361+ let with: BTreeMap<String, String> = match step.get("with") {
362+ Some(Value::Object(with)) => with
363+ .iter()
364+ .map(|(k, v)| {
365+ let value = self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).unwrap_or(Value::Null);
366+ (k.clone(), expr::to_text(&value))
367+ })
368+ .collect(),
369+ _ => BTreeMap::new(),
370+ };
371+ self.uses(&uses, &with, &env, frame, &title, id.as_deref(), timeout)
372+ } else {
373+ self.log.line("##[error]A step needs `run` or `uses`.");
374+ (false, BTreeMap::new())
375+ };
376+
377+ let outcome = if ok { "success" } else { "failure" };
378+ let conclusion = if ok || continue_on_error { "success" } else { "failure" };
379+ if !ok && continue_on_error {
380+ self.log.line("##[warning]The step failed, and `continue-on-error` lets the job go on.");
381+ }
382+ if let Some(id) = &id {
383+ let outputs: Map<String, Value> = outputs.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
384+ frame.steps.insert(id.clone(), json!({ "outputs": outputs, "outcome": outcome, "conclusion": conclusion }));
385+ }
386+ if conclusion == "failure" {
387+ self.failed = true;
388+ }
389+ if report {
390+ self.log.step_state(number, &title, "completed", Some(conclusion));
391+ }
392+ conclusion == "success"
393+ }
394+
395+ fn report_steps(&self) {
396+ self.log.steps(&self.step_names);
397+ }
398+}
399+
400+/// An id for files, unique enough within one job.
401+fn rand_id() -> u64 {
402+ use std::sync::atomic::{AtomicU64, Ordering};
403+ static NEXT: AtomicU64 = AtomicU64::new(1);
404+ let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
405+ nanos ^ (NEXT.fetch_add(1, Ordering::Relaxed) << 48)
406+}
407+
408+fn interpolated_map(job: &Job, value: Option<&Value>, contexts: &Map<String, Value>) -> BTreeMap<String, String> {
409+ let mut out = BTreeMap::new();
410+ if let Some(Value::Object(map)) = value {
411+ for (name, value) in map {
412+ let value = job.with_scope(contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
413+ out.insert(name.clone(), expr::to_text(&value));
414+ }
415+ }
416+ out
417+}
418+
419+fn setup(spec: Value, api: Api) -> Result<Job> {
420+ let masks: Vec<String> = spec["masks"].as_array().map(|m| m.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default();
421+ let log = Log::new(api, masks);
422+ let workspace = PathBuf::from(WORKSPACE);
423+ let temp = PathBuf::from(TEMP);
424+ std::fs::create_dir_all(&workspace).context("could not make the workspace")?;
425+ std::fs::create_dir_all(&temp).context("could not make the temporary folder")?;
426+ std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?;
427+
428+ // This process's environment, less what only it should see.
429+ let mut base_env: BTreeMap<String, String> =
430+ std::env::vars().filter(|(name, _)| !matches!(name.as_str(), "ACTIONS_TOKEN" | "ACTIONS_JOB" | "MODE") && !name.starts_with("G1T_")).collect();
431+ base_env.insert("HOME".into(), std::env::var("HOME").unwrap_or_else(|_| "/home/node".into()));
432+ base_env.extend(text_map(spec.get("variables")));
433+ base_env.insert("GITHUB_EVENT_PATH".into(), temp.join("event.json").display().to_string());
434+
435+ let mut contexts: Map<String, Value> = spec["contexts"].as_object().cloned().unwrap_or_default();
436+ contexts.insert("github".into(), spec["github"].clone());
437+ let debug = contexts
438+ .get("secrets")
439+ .and_then(|s| s.get("ACTIONS_STEP_DEBUG"))
440+ .or_else(|| contexts.get("vars").and_then(|v| v.get("ACTIONS_STEP_DEBUG")))
441+ .is_some_and(|v| expr::to_text(v) == "true");
442+
443+ let timeout = spec["timeoutMinutes"].as_u64().unwrap_or(60);
444+ let mut job = Job {
445+ log,
446+ spec,
447+ workspace,
448+ temp,
449+ base_env,
450+ added_env: BTreeMap::new(),
451+ path_prepend: Vec::new(),
452+ workflow_env: BTreeMap::new(),
453+ job_env: BTreeMap::new(),
454+ contexts,
455+ failed: false,
456+ posts: Vec::new(),
457+ step_names: Vec::new(),
458+ deadline: Instant::now() + Duration::from_secs(timeout * 60),
459+ debug,
460+ last_node_outputs: BTreeMap::new(),
461+ last_node_state: BTreeMap::new(),
462+ };
463+
464+ // The workflow's env reads github, secrets, inputs and vars; the job's
465+ // also its matrix, needs and strategy.
466+ let mut contexts = job.contexts.clone();
467+ contexts.insert("env".into(), json!({}));
468+ job.workflow_env = interpolated_map(&job, job.spec["workflow"].get("env"), &contexts);
469+ contexts.insert("env".into(), Value::Object(job.workflow_env.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
470+ job.job_env = interpolated_map(&job, job.spec["spec"].get("env"), &contexts);
471+ Ok(job)
472+}
473+
474+/// The job's `defaults.run`, its own over the workflow's.
475+fn run_defaults(spec: &Value) -> Map<String, Value> {
476+ let mut defaults = spec["workflow"]["defaults"]["run"].as_object().cloned().unwrap_or_default();
477+ if let Some(own) = spec["spec"]["defaults"]["run"].as_object() {
478+ defaults.extend(own.clone());
479+ }
480+ defaults
481+}
482+
483+fn run_job(job: &mut Job) {
484+ let steps: Vec<Map<String, Value>> = job.spec["spec"]["steps"]
485+ .as_array()
486+ .map(|steps| steps.iter().filter_map(|s| s.as_object().cloned()).collect())
487+ .unwrap_or_default();
488+ let defaults = run_defaults(&job.spec);
489+
490+ // Step names as they read before anything has run.
491+ let frame = Frame::default();
492+ let env = job.env_context(&frame);
493+ let contexts = job.contexts_for(&frame, &env);
494+ job.step_names = steps
495+ .iter()
496+ .map(|step| match step.get("name").map(expr::to_text) {
497+ Some(name) => job.with_scope(&contexts, |scope| expr::interpolate(&name, scope)).unwrap_or(name),
498+ None => default_title(step),
499+ })
500+ .collect();
501+ job.report_steps();
502+
503+ job.log.step(0);
504+ job.log.line(&format!("Job: {}", job.spec["name"].as_str().unwrap_or_default()));
505+ job.log.line("Runner: g1t, Linux X64 (Debian bookworm, Node 22, Python 3, Go, Rust)");
506+ if let Some(Value::Object(matrix)) = job.contexts.get("matrix")
507+ && !matrix.is_empty()
508+ {
509+ job.log.line(&format!("Matrix: {}", serde_json::to_string(matrix).unwrap_or_default()));
510+ }
511+ job.log.flush();
512+
513+ let mut frame = Frame::default();
514+ for (index, step) in steps.iter().enumerate() {
515+ job.step(&mut frame, step, index as u32 + 1, true, &defaults);
516+ if job.remaining().is_zero() {
517+ job.log.line("##[error]The job ran past its time limit.");
518+ job.failed = true;
519+ break;
520+ }
521+ }
522+
523+ // Post steps, last registered first.
524+ let posts: Vec<Post> = std::mem::take(&mut job.posts);
525+ for post in posts.into_iter().rev() {
526+ let number = job.step_names.len() as u32 + 1;
527+ job.step_names.push(post.name.clone());
528+ job.report_steps();
529+ let contexts = job.contexts_for(&frame, &job.env_context(&frame));
530+ let run_it = job.with_scope(&contexts, |scope| expr::condition(&post.condition, scope)).unwrap_or(true);
531+ if !run_it {
532+ job.log.step_state(number, &post.name, "completed", Some("skipped"));
533+ continue;
534+ }
535+ job.log.step(number);
536+ job.log.step_state(number, &post.name, "in_progress", None);
537+ let ok = job.run_node(&post.action_dir, &post.script, &post.env);
538+ job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" }));
539+ if !ok {
540+ job.failed = true;
541+ }
542+ }
543+
544+ // The job's outputs, read now that every step has run.
545+ let env = job.env_context(&frame);
546+ let contexts = job.contexts_for(&frame, &env);
547+ let mut outputs = Map::new();
548+ if let Some(Value::Object(declared)) = job.spec["spec"].get("outputs") {
549+ for (name, value) in declared {
550+ let value = job.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
551+ outputs.insert(name.clone(), Value::String(expr::to_text(&value)));
552+ }
553+ }
554+ let conclusion = if job.failed { "failure" } else { "success" };
555+ job.log.done(conclusion, &outputs, None);
556+}
557+
558+pub(crate) fn main() -> i32 {
559+ let api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) {
560+ (Ok(base), Ok(job), Ok(token)) => Api { base, job, token },
561+ _ => {
562+ eprintln!("g1t-runner: G1T_API, ACTIONS_JOB and ACTIONS_TOKEN are needed");
563+ return 2;
564+ }
565+ };
566+ let spec = match api.spec() {
567+ Ok(spec) => spec,
568+ Err(error) => {
569+ eprintln!("g1t-runner: could not fetch the job: {error:#}");
570+ api.report(json!({ "kind": "done", "conclusion": "failure", "reason": format!("The runner could not fetch the job: {error}") }));
571+ return 1;
572+ }
573+ };
574+ let reporter = Api {
575+ base: api.base.clone(),
576+ job: api.job.clone(),
577+ token: api.token.clone(),
578+ };
579+ let mut job = match setup(spec, reporter) {
580+ Ok(job) => job,
581+ Err(error) => {
582+ api.report(json!({ "kind": "done", "conclusion": "failure", "reason": format!("The runner could not set up: {error:#}") }));
583+ return 1;
584+ }
585+ };
586+ run_job(&mut job);
587+ if job.failed { 1 } else { 0 }
588+}
+211−0
1+//! Running one process for a step: its output streamed to the log as it
2+//! comes, with GitHub's workflow commands (`::error::`, `::group::`,
3+//! `::add-mask::`…) read out of it.
4+
5+use std::collections::BTreeMap;
6+use std::io::{BufRead, BufReader, Read};
7+use std::process::{Command, Stdio};
8+use std::sync::mpsc;
9+use std::time::{Duration, Instant};
10+
11+use serde_json::{Map, Value};
12+
13+use super::report::Log;
14+
15+/// What a step's workflow commands left behind.
16+#[derive(Default)]
17+pub(crate) struct Commands {
18+ /// `::set-output` (old, still honoured).
19+ pub(crate) outputs: BTreeMap<String, String>,
20+ /// `::save-state`, for the action's post step.
21+ pub(crate) state: BTreeMap<String, String>,
22+ /// Set by `::stop-commands::token` until `::token::`.
23+ pub(crate) stopped: Option<String>,
24+ /// Whether `::debug::` lines are shown (`ACTIONS_STEP_DEBUG`).
25+ pub(crate) debug: bool,
26+}
27+
28+/// `%25`, `%0D`, `%0A`, and in properties `%3A` and `%2C`, as the
29+/// toolkit escapes them.
30+fn unescape(text: &str, property: bool) -> String {
31+ let mut out = text.replace("%0D", "\r").replace("%0A", "\n");
32+ if property {
33+ out = out.replace("%3A", ":").replace("%2C", ",");
34+ }
35+ out.replace("%25", "%")
36+}
37+
38+/// `::name key=value,key=value::message`, if the line is a command.
39+pub(crate) fn parse_command(line: &str) -> Option<(String, Map<String, Value>, String)> {
40+ let rest = line.trim_start().strip_prefix("::")?;
41+ let end = rest.find("::")?;
42+ let (head, data) = (&rest[..end], &rest[end + 2..]);
43+ let (name, properties) = match head.split_once(' ') {
44+ Some((name, properties)) => (name, properties),
45+ None => (head, ""),
46+ };
47+ if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
48+ return None;
49+ }
50+ let mut map = Map::new();
51+ for pair in properties.split(',').filter(|p| !p.trim().is_empty()) {
52+ if let Some((key, value)) = pair.split_once('=') {
53+ map.insert(key.trim().to_owned(), Value::String(unescape(value, true)));
54+ }
55+ }
56+ Some((name.to_owned(), map, unescape(data, false)))
57+}
58+
59+impl Commands {
60+ /// Handles one line of output: a command is acted on, and the line to
61+ /// show (if any) is returned.
62+ pub(crate) fn handle(&mut self, line: &str, log: &mut Log) -> Option<String> {
63+ if let Some(token) = &self.stopped {
64+ if line.trim() == format!("::{token}::") {
65+ self.stopped = None;
66+ return None;
67+ }
68+ return Some(line.to_owned());
69+ }
70+ let Some((name, properties, data)) = parse_command(line) else {
71+ return Some(line.to_owned());
72+ };
73+ match name.as_str() {
74+ "add-mask" => {
75+ if !data.trim().is_empty() {
76+ log.masks.push(data.trim().to_owned());
77+ }
78+ None
79+ }
80+ "error" | "warning" | "notice" => {
81+ log.annotation(&name, &data, &properties);
82+ let label = match name.as_str() {
83+ "error" => "Error",
84+ "warning" => "Warning",
85+ _ => "Notice",
86+ };
87+ Some(format!("##[{name}]{label}: {data}"))
88+ }
89+ "group" => Some(format!("##[group]{data}")),
90+ "endgroup" => Some("##[endgroup]".to_owned()),
91+ "debug" => self.debug.then(|| format!("##[debug]{data}")),
92+ "set-output" => {
93+ if let Some(name) = properties.get("name").and_then(Value::as_str) {
94+ self.outputs.insert(name.to_owned(), data);
95+ }
96+ None
97+ }
98+ "save-state" => {
99+ if let Some(name) = properties.get("name").and_then(Value::as_str) {
100+ self.state.insert(name.to_owned(), data);
101+ }
102+ None
103+ }
104+ "stop-commands" => {
105+ self.stopped = Some(data);
106+ None
107+ }
108+ "echo" => None,
109+ "add-path" | "set-env" => Some(format!(
110+ "##[error]The `{name}` command is disabled, as on GitHub. Write to the file in $GITHUB_{} instead.",
111+ if name == "add-path" { "PATH" } else { "ENV" }
112+ )),
113+ _ => Some(line.to_owned()),
114+ }
115+ }
116+}
117+
118+/// How a process ended.
119+pub(crate) enum Ended {
120+ Exited(i32),
121+ TimedOut,
122+}
123+
124+/// Runs the command, sending its output (stdout and stderr together, a
125+/// line at a time) through `commands` to the log, until it ends or
126+/// `timeout` passes.
127+pub(crate) fn run(mut command: Command, timeout: Duration, log: &mut Log, commands: &mut Commands) -> std::io::Result<Ended> {
128+ command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
129+ let mut child = command.spawn()?;
130+ let (sender, lines) = mpsc::channel::<String>();
131+ let mut readers = Vec::new();
132+ let pipes: Vec<Box<dyn Read + Send>> = vec![
133+ Box::new(child.stdout.take().expect("piped")),
134+ Box::new(child.stderr.take().expect("piped")),
135+ ];
136+ for pipe in pipes {
137+ let sender = sender.clone();
138+ readers.push(std::thread::spawn(move || {
139+ let mut reader = BufReader::new(pipe);
140+ let mut buffer = Vec::new();
141+ loop {
142+ buffer.clear();
143+ match reader.read_until(b'\n', &mut buffer) {
144+ Ok(0) | Err(_) => break,
145+ Ok(_) => {
146+ let text = String::from_utf8_lossy(&buffer);
147+ let text = text.trim_end_matches(['\n', '\r']);
148+ // A progress bar redraws with \r; keep its last state.
149+ let text = text.rsplit('\r').next().unwrap_or(text);
150+ if sender.send(text.to_owned()).is_err() {
151+ break;
152+ }
153+ }
154+ }
155+ }
156+ }));
157+ }
158+ drop(sender);
159+ let deadline = Instant::now() + timeout;
160+ let mut timed_out = false;
161+ loop {
162+ match lines.recv_timeout(Duration::from_millis(250)) {
163+ Ok(line) => {
164+ if let Some(shown) = commands.handle(&line, log) {
165+ log.line(&shown);
166+ }
167+ }
168+ Err(mpsc::RecvTimeoutError::Timeout) => log.tick(),
169+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
170+ }
171+ if Instant::now() >= deadline {
172+ timed_out = true;
173+ let _ = child.kill();
174+ break;
175+ }
176+ }
177+ let status = child.wait()?;
178+ for reader in readers {
179+ let _ = reader.join();
180+ }
181+ // Whatever arrived after the readers finished.
182+ while let Ok(line) = lines.try_recv() {
183+ if let Some(shown) = commands.handle(&line, log) {
184+ log.line(&shown);
185+ }
186+ }
187+ if timed_out {
188+ return Ok(Ended::TimedOut);
189+ }
190+ Ok(Ended::Exited(status.code().unwrap_or(1)))
191+}
192+
193+#[cfg(test)]
194+mod tests {
195+ use super::parse_command;
196+
197+ #[test]
198+ fn commands_are_read_with_their_properties() {
199+ let (name, properties, data) = parse_command("::error file=app.js,line=10,title=Bad%3A thing::Something%0Abroke").unwrap();
200+ assert_eq!(name, "error");
201+ assert_eq!(properties["file"], "app.js");
202+ assert_eq!(properties["line"], "10");
203+ assert_eq!(properties["title"], "Bad: thing");
204+ assert_eq!(data, "Something\nbroke");
205+ let (name, properties, data) = parse_command("::group::Install").unwrap();
206+ assert_eq!((name.as_str(), properties.len(), data.as_str()), ("group", 0, "Install"));
207+ assert_eq!(parse_command("::set-output name=version::1.2.3").unwrap().1["name"], "version");
208+ assert!(parse_command("plain text").is_none());
209+ assert!(parse_command(":: not a command").is_none());
210+ }
211+}
+132−0
1+//! Telling g1t how a job is going: its steps, its log in batches, its
2+//! annotations, and how it ended. Every report carries the job's token.
3+
4+use std::time::{Duration, Instant};
5+
6+use anyhow::Result;
7+use serde_json::{Value, json};
8+
9+/// How long log lines wait before they are sent.
10+const FLUSH_EVERY: Duration = Duration::from_millis(1500);
11+/// How much log is sent at once.
12+const FLUSH_BYTES: usize = 64 * 1024;
13+
14+pub(crate) struct Api {
15+ pub(crate) base: String,
16+ pub(crate) job: String,
17+ pub(crate) token: String,
18+}
19+
20+impl Api {
21+ pub(crate) fn spec(&self) -> Result<Value> {
22+ let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job))
23+ .timeout(Duration::from_secs(60))
24+ .send_json(json!({ "token": self.token }))?;
25+ Ok(response.into_json()?)
26+ }
27+
28+ pub(crate) fn report(&self, report: Value) {
29+ // A report that cannot be sent is tried a few times, then dropped:
30+ // the job goes on, and g1t notices a silent job by itself.
31+ for attempt in 0..3 {
32+ let sent = ureq::post(&format!("{}/actions/jobs/{}", self.base, self.job))
33+ .timeout(Duration::from_secs(30))
34+ .send_json(json!({ "token": self.token, "report": report }));
35+ match sent {
36+ Ok(_) => return,
37+ // Refused: the job was cancelled or finished; nothing to retry.
38+ Err(ureq::Error::Status(code, _)) if (400..500).contains(&code) => return,
39+ Err(_) => std::thread::sleep(Duration::from_millis(500 * (attempt + 1))),
40+ }
41+ }
42+ }
43+}
44+
45+/// The job's log, masked, sent in batches.
46+pub(crate) struct Log {
47+ pub(crate) api: Api,
48+ pub(crate) masks: Vec<String>,
49+ step: u32,
50+ buffer: String,
51+ last: Instant,
52+}
53+
54+impl Log {
55+ pub(crate) fn new(api: Api, masks: Vec<String>) -> Log {
56+ Log {
57+ api,
58+ masks,
59+ step: 0,
60+ buffer: String::new(),
61+ last: Instant::now(),
62+ }
63+ }
64+
65+ /// Starts writing to step `number` (0 for the job's setup).
66+ pub(crate) fn step(&mut self, number: u32) {
67+ self.flush();
68+ self.step = number;
69+ }
70+
71+ pub(crate) fn mask(&self, text: &str) -> String {
72+ let mut out = text.to_owned();
73+ for mask in self.masks.iter().filter(|mask| !mask.is_empty()) {
74+ if out.contains(mask.as_str()) {
75+ out = out.replace(mask.as_str(), "***");
76+ }
77+ }
78+ out
79+ }
80+
81+ pub(crate) fn line(&mut self, text: &str) {
82+ let masked = self.mask(text);
83+ self.buffer.push_str(&masked);
84+ self.buffer.push('\n');
85+ if self.buffer.len() >= FLUSH_BYTES || self.last.elapsed() >= FLUSH_EVERY {
86+ self.flush();
87+ }
88+ }
89+
90+ /// Sends what is waiting if it has waited long enough.
91+ pub(crate) fn tick(&mut self) {
92+ if !self.buffer.is_empty() && self.last.elapsed() >= FLUSH_EVERY {
93+ self.flush();
94+ }
95+ }
96+
97+ pub(crate) fn flush(&mut self) {
98+ self.last = Instant::now();
99+ if self.buffer.is_empty() {
100+ return;
101+ }
102+ let text = std::mem::take(&mut self.buffer);
103+ self.api.report(json!({ "kind": "log", "step": self.step, "text": text }));
104+ }
105+
106+ pub(crate) fn steps(&self, names: &[String]) {
107+ self.api.report(json!({ "kind": "steps", "steps": names }));
108+ }
109+
110+ pub(crate) fn step_state(&mut self, number: u32, name: &str, status: &str, conclusion: Option<&str>) {
111+ self.flush();
112+ let name = self.mask(name);
113+ self.api.report(json!({ "kind": "step", "number": number, "name": name, "status": status, "conclusion": conclusion }));
114+ }
115+
116+ pub(crate) fn annotation(&mut self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) {
117+ let message = self.mask(message);
118+ self.api.report(json!({
119+ "kind": "annotation",
120+ "level": level,
121+ "message": message,
122+ "title": properties.get("title"),
123+ "file": properties.get("file"),
124+ "line": properties.get("line").and_then(|l| l.as_str()).and_then(|l| l.parse::<u32>().ok()),
125+ }));
126+ }
127+
128+ pub(crate) fn done(&mut self, conclusion: &str, outputs: &serde_json::Map<String, Value>, reason: Option<&str>) {
129+ self.flush();
130+ self.api.report(json!({ "kind": "done", "conclusion": conclusion, "outputs": outputs, "reason": reason }));
131+ }
132+}
+370−0
1+//! `uses:` steps: `actions/checkout` done natively against g1t, actions
2+//! fetched from GitHub and run as they are (JavaScript and composite), and
3+//! a few of GitHub's own whose services g1t does not have yet.
4+
5+use std::collections::BTreeMap;
6+use std::path::{Path, PathBuf};
7+use std::process::Command;
8+use std::time::Duration;
9+
10+use base64::Engine;
11+use base64::engine::general_purpose::STANDARD;
12+use g1t_actions::expr;
13+use g1t_actions::workflow::yaml_to_json;
14+use serde_json::{Map, Value, json};
15+
16+use super::files::StepFiles;
17+use super::process::{self, Commands, Ended};
18+use super::{Frame, Job, Post};
19+
20+const ACTIONS_DIR: &str = "/home/runner/_actions";
21+
22+/// Where an action comes from.
23+enum Source {
24+ Local(PathBuf),
25+ GitHub { owner: String, repo: String, path: String, git_ref: String },
26+}
27+
28+fn safe(part: &str) -> bool {
29+ !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')) && !part.contains("..")
30+}
31+
32+impl Job {
33+ /// Runs a git command, logging it; the credential header is never logged.
34+ fn git(&mut self, dir: &Path, args: &[&str], auth: Option<&str>) -> bool {
35+ let shown: Vec<&str> = args.to_vec();
36+ self.log.line(&format!("[command]git {}", shown.join(" ")));
37+ let mut command = Command::new("git");
38+ command.current_dir(dir);
39+ if let Some(header) = auth {
40+ command.args(["-c", &format!("http.extraheader={header}")]);
41+ }
42+ command.args(args).env("GIT_TERMINAL_PROMPT", "0");
43+ let mut commands = Commands::default();
44+ matches!(process::run(command, Duration::from_secs(600), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
45+ }
46+
47+ /// `actions/checkout`, against g1t.
48+ fn checkout(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
49+ let checkout = self.spec["checkout"].clone();
50+ let own = checkout["repository"].as_str().unwrap_or_default().to_owned();
51+ let server = self.contexts["github"]["server_url"].as_str().unwrap_or("https://g1t.sh").to_owned();
52+ let repository = with.get("repository").filter(|r| !r.is_empty()).cloned().unwrap_or(own.clone());
53+ let same = repository.eq_ignore_ascii_case(&own);
54+ let token = with.get("token").filter(|t| !t.is_empty()).cloned().or_else(|| checkout["token"].as_str().map(str::to_owned)).unwrap_or_default();
55+ let url = if same { checkout["url"].as_str().unwrap_or_default().to_owned() } else { format!("{server}/{repository}.git") };
56+ let path = with.get("path").filter(|p| !p.is_empty()).map_or(self.workspace.clone(), |p| self.workspace.join(p));
57+ let depth: u32 = with.get("fetch-depth").and_then(|d| d.parse().ok()).unwrap_or(1);
58+ let wanted_ref = with.get("ref").filter(|r| !r.is_empty()).cloned();
59+ let auth = (!token.is_empty()).then(|| format!("AUTHORIZATION: basic {}", STANDARD.encode(format!("x-access-token:{token}"))));
60+
61+ self.log.line(&format!("Checking out {repository} into {}", path.display()));
62+ if path.exists() {
63+ let _ = std::fs::remove_dir_all(&path);
64+ }
65+ if let Err(error) = std::fs::create_dir_all(&path) {
66+ self.log.line(&format!("##[error]Could not make {}: {error}", path.display()));
67+ return (false, BTreeMap::new());
68+ }
69+ let _ = Command::new("git").args(["config", "--global", "--add", "safe.directory", "*"]).status();
70+ if !self.git(&path, &["init", "--quiet"], None) || !self.git(&path, &["remote", "add", "origin", &url], None) {
71+ return (false, BTreeMap::new());
72+ }
73+
74+ // What to fetch, and which commit to end up on.
75+ let run_ref = checkout["ref"].as_str().unwrap_or_default().to_owned();
76+ let run_sha = checkout["sha"].as_str().unwrap_or_default().to_owned();
77+ let is_sha = |r: &str| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit());
78+ let (fetch, sha, branch): (String, Option<String>, Option<String>) = match &wanted_ref {
79+ Some(r) if is_sha(r) => ("HEAD".into(), Some(r.clone()), None),
80+ Some(r) if r.starts_with("refs/") => (r.clone(), None, r.strip_prefix("refs/heads/").map(str::to_owned)),
81+ Some(r) => (r.clone(), None, Some(r.clone())),
82+ None if same && run_ref.starts_with("refs/pull/") => ("HEAD".into(), Some(run_sha.clone()), None),
83+ None if same => (run_ref.clone(), Some(run_sha.clone()), run_ref.strip_prefix("refs/heads/").map(str::to_owned)),
84+ None => ("HEAD".into(), None, None),
85+ };
86+ let depth_arg = format!("--depth={depth}");
87+ let mut args = vec!["fetch", "--no-tags", "--prune", "--quiet"];
88+ if depth > 0 {
89+ args.push(&depth_arg);
90+ }
91+ if with.get("fetch-tags").is_some_and(|t| t == "true") {
92+ args.retain(|a| *a != "--no-tags");
93+ }
94+ args.push("origin");
95+ args.push(&fetch);
96+ if !self.git(&path, &args, auth.as_deref()) {
97+ self.log.line(&format!("##[error]Could not fetch {fetch} from {repository}."));
98+ return (false, BTreeMap::new());
99+ }
100+ let target = sha.clone().unwrap_or_else(|| "FETCH_HEAD".into());
101+ // The commit may be further back than a shallow fetch reaches.
102+ let present = Command::new("git").current_dir(&path).args(["cat-file", "-e", &format!("{target}^{{commit}}")]).status().is_ok_and(|s| s.success());
103+ if !present && !self.git(&path, &["fetch", "--no-tags", "--quiet", "origin"], auth.as_deref()) {
104+ return (false, BTreeMap::new());
105+ }
106+ let checked_out = match &branch {
107+ Some(branch) => self.git(&path, &["checkout", "--quiet", "--force", "-B", branch, &target], None),
108+ None => self.git(&path, &["checkout", "--quiet", "--force", "--detach", &target], None),
109+ };
110+ if !checked_out {
111+ return (false, BTreeMap::new());
112+ }
113+ if with.get("persist-credentials").is_none_or(|p| p != "false")
114+ && let Some(header) = &auth
115+ {
116+ let key = format!("http.{server}/.extraheader");
117+ let _ = Command::new("git").current_dir(&path).args(["config", "--local", &key, header]).status();
118+ }
119+ if let Some(submodules) = with.get("submodules").filter(|s| *s == "true" || *s == "recursive") {
120+ let mut args = vec!["submodule", "update", "--init", "--quiet"];
121+ if submodules == "recursive" {
122+ args.push("--recursive");
123+ }
124+ if !self.git(&path, &args, auth.as_deref()) {
125+ self.log.line("##[warning]Submodules could not all be checked out; only those hosted on g1t can be.");
126+ }
127+ }
128+ if with.get("lfs").is_some_and(|l| l == "true") {
129+ self.log.line("##[warning]Git LFS files are not fetched on g1t yet.");
130+ }
131+ let commit = Command::new("git").current_dir(&path).args(["rev-parse", "HEAD"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).unwrap_or_default();
132+ self.log.line(&format!("Checked out {commit}"));
133+ let mut outputs = BTreeMap::new();
134+ outputs.insert("ref".into(), wanted_ref.unwrap_or(run_ref));
135+ outputs.insert("commit".into(), commit);
136+ (true, outputs)
137+ }
138+
139+ /// Fetches an action from GitHub, once per job.
140+ fn fetch_action(&mut self, owner: &str, repo: &str, git_ref: &str) -> Option<PathBuf> {
141+ let dir = Path::new(ACTIONS_DIR).join(owner).join(repo).join(git_ref);
142+ if dir.join(".g1t-fetched").exists() {
143+ return Some(dir);
144+ }
145+ if !(safe(owner) && safe(repo) && safe(git_ref)) {
146+ self.log.line(&format!("##[error]`{owner}/{repo}@{git_ref}` is not a name g1t can fetch."));
147+ return None;
148+ }
149+ self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}'"));
150+ let _ = std::fs::create_dir_all(&dir);
151+ let url = format!("https://codeload.github.com/{owner}/{repo}/tar.gz/{git_ref}");
152+ let script = format!("set -o pipefail; curl -fsSL --retry 3 '{url}' | tar -xz -C '{}' --strip-components=1", dir.display());
153+ let mut command = Command::new("bash");
154+ command.args(["-c", &script]);
155+ let mut commands = Commands::default();
156+ match process::run(command, Duration::from_secs(300), &mut self.log, &mut commands) {
157+ Ok(Ended::Exited(0)) => {
158+ let _ = std::fs::write(dir.join(".g1t-fetched"), "");
159+ Some(dir)
160+ }
161+ _ => {
162+ self.log.line(&format!("##[error]Could not download {owner}/{repo}@{git_ref} from GitHub."));
163+ let _ = std::fs::remove_dir_all(&dir);
164+ None
165+ }
166+ }
167+ }
168+
169+ /// Runs a JavaScript file of an action with Node.
170+ pub(crate) fn run_node(&mut self, action_dir: &Path, script: &str, env: &BTreeMap<String, String>) -> bool {
171+ let id = format!("node{}", super::rand_id());
172+ let Ok(files) = StepFiles::new(&self.temp, &id) else { return false };
173+ let mut command = Command::new("node");
174+ command.arg(action_dir.join(script)).current_dir(&self.workspace).env_clear().envs(self.process_env(env, &files));
175+ let mut commands = Commands {
176+ debug: false,
177+ ..Commands::default()
178+ };
179+ let ended = process::run(command, Duration::from_secs(6 * 3600).min(self.deadline_left()), &mut self.log, &mut commands);
180+ let ok = matches!(ended, Ok(Ended::Exited(0)));
181+ if let Ok(Ended::Exited(code)) = ended
182+ && code != 0
183+ {
184+ self.log.line(&format!("##[error]The action exited with code {code}."));
185+ }
186+ let (outputs, state) = self.absorb(&files, &commands);
187+ self.last_node_outputs = outputs;
188+ self.last_node_state = state;
189+ ok
190+ }
191+
192+ fn deadline_left(&self) -> Duration {
193+ self.remaining_time()
194+ }
195+
196+ /// Runs a `uses:` step. Returns whether it succeeded, and its outputs.
197+ #[allow(clippy::too_many_arguments)]
198+ pub(crate) fn uses(
199+ &mut self,
200+ uses: &str,
201+ with: &BTreeMap<String, String>,
202+ env: &BTreeMap<String, String>,
203+ frame: &Frame,
204+ title: &str,
205+ id: Option<&str>,
206+ _timeout: Duration,
207+ ) -> (bool, BTreeMap<String, String>) {
208+ let uses = uses.trim();
209+ if uses.starts_with("docker://") {
210+ self.log.line(&format!("##[error]`{uses}`: Docker actions do not run on g1t yet."));
211+ return (false, BTreeMap::new());
212+ }
213+ let (name, git_ref) = uses.split_once('@').unwrap_or((uses, ""));
214+ let lower = name.to_ascii_lowercase();
215+ match lower.as_str() {
216+ "actions/checkout" => return self.checkout(with),
217+ "actions/upload-artifact" => {
218+ self.log.line("##[warning]Artifacts are not kept on g1t yet: nothing was uploaded, and the job goes on.");
219+ return (true, BTreeMap::new());
220+ }
221+ "actions/download-artifact" => {
222+ self.log.line("##[error]Artifacts are not kept on g1t yet, so there is nothing to download.");
223+ return (false, BTreeMap::new());
224+ }
225+ _ => {}
226+ }
227+ let source = if let Some(local) = name.strip_prefix("./") {
228+ Source::Local(self.workspace.join(local))
229+ } else {
230+ let mut parts = name.splitn(3, '/');
231+ let (Some(owner), Some(repo)) = (parts.next(), parts.next()) else {
232+ self.log.line(&format!("##[error]`{uses}` is not an action: use owner/repo@ref, owner/repo/path@ref, or ./path."));
233+ return (false, BTreeMap::new());
234+ };
235+ if git_ref.is_empty() {
236+ self.log.line(&format!("##[error]`{uses}` needs a version, such as @v4."));
237+ return (false, BTreeMap::new());
238+ }
239+ Source::GitHub {
240+ owner: owner.to_owned(),
241+ repo: repo.to_owned(),
242+ path: parts.next().unwrap_or_default().to_owned(),
243+ git_ref: git_ref.to_owned(),
244+ }
245+ };
246+ let (dir, repository) = match &source {
247+ Source::Local(dir) => (dir.clone(), String::new()),
248+ Source::GitHub { owner, repo, path, git_ref } => match self.fetch_action(owner, repo, git_ref) {
249+ Some(root) => (if path.is_empty() { root } else { root.join(path) }, format!("{owner}/{repo}")),
250+ None => return (false, BTreeMap::new()),
251+ },
252+ };
253+ let manifest = ["action.yml", "action.yaml"].iter().map(|f| dir.join(f)).find(|p| p.exists());
254+ let Some(manifest) = manifest else {
255+ self.log.line(&format!("##[error]`{uses}` has no action.yml."));
256+ return (false, BTreeMap::new());
257+ };
258+ let action = match std::fs::read_to_string(&manifest).ok().and_then(|text| serde_yaml::from_str::<serde_yaml::Value>(&text).ok()) {
259+ Some(yaml) => yaml_to_json(&yaml),
260+ None => {
261+ self.log.line(&format!("##[error]`{uses}`: its action.yml does not read."));
262+ return (false, BTreeMap::new());
263+ }
264+ };
265+
266+ // Inputs: what the step gives, else the action's defaults.
267+ let env_context = env.clone();
268+ let contexts = self.contexts_for(frame, &env_context);
269+ let mut inputs: BTreeMap<String, String> = BTreeMap::new();
270+ if let Some(Value::Object(declared)) = action.get("inputs") {
271+ for (input, spec) in declared {
272+ let given = with.iter().find(|(k, _)| k.eq_ignore_ascii_case(input)).map(|(_, v)| v.clone());
273+ let value = match given {
274+ Some(value) => value,
275+ None => match spec.get("default") {
276+ Some(default) => {
277+ let default = self.with_scope(&contexts, |scope| expr::interpolate_value(default, scope)).unwrap_or(Value::Null);
278+ expr::to_text(&default)
279+ }
280+ None => String::new(),
281+ },
282+ };
283+ inputs.insert(input.clone(), value);
284+ }
285+ }
286+ for (key, value) in with {
287+ if !inputs.keys().any(|k| k.eq_ignore_ascii_case(key)) {
288+ inputs.insert(key.clone(), value.clone());
289+ }
290+ }
291+
292+ let runs = action.get("runs").cloned().unwrap_or(Value::Null);
293+ let using = runs.get("using").map(expr::to_text).unwrap_or_default().to_ascii_lowercase();
294+ let mut step_env = env.clone();
295+ step_env.insert("GITHUB_ACTION".into(), id.map_or_else(|| format!("__{}", repository.replace('/', "_")), str::to_owned));
296+ step_env.insert("GITHUB_ACTION_REPOSITORY".into(), repository.clone());
297+ step_env.insert("GITHUB_ACTION_REF".into(), git_ref.to_owned());
298+ step_env.insert("GITHUB_ACTION_PATH".into(), dir.display().to_string());
299+
300+ if using.starts_with("node") {
301+ for (input, value) in &inputs {
302+ step_env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone());
303+ }
304+ let condition_of = |key: &str| runs.get(key).map(expr::to_text).unwrap_or_else(|| "always()".into());
305+ if let Some(pre) = runs.get("pre").map(expr::to_text) {
306+ let run_pre = self.with_scope(&contexts, |scope| expr::condition(&condition_of("pre-if"), scope)).unwrap_or(true);
307+ if run_pre && !self.run_node(&dir, &pre, &step_env) {
308+ return (false, BTreeMap::new());
309+ }
310+ }
311+ let Some(main) = runs.get("main").map(expr::to_text) else {
312+ self.log.line(&format!("##[error]`{uses}` has no `runs.main`."));
313+ return (false, BTreeMap::new());
314+ };
315+ let ok = self.run_node(&dir, &main, &step_env);
316+ let outputs = std::mem::take(&mut self.last_node_outputs);
317+ let state = std::mem::take(&mut self.last_node_state);
318+ if let Some(post) = runs.get("post").map(expr::to_text) {
319+ let mut post_env = step_env.clone();
320+ for (name, value) in state {
321+ post_env.insert(format!("STATE_{name}"), value);
322+ }
323+ self.posts.push(Post {
324+ name: format!("Post {title}"),
325+ action_dir: dir.clone(),
326+ script: post,
327+ condition: condition_of("post-if"),
328+ env: post_env,
329+ });
330+ }
331+ return (ok, outputs);
332+ }
333+ if using == "composite" {
334+ let mut inner = Frame {
335+ steps: Map::new(),
336+ inputs: Some(Value::Object(inputs.iter().map(|(k, v)| (k.clone(), json!(v))).collect())),
337+ action_path: Some(dir.display().to_string()),
338+ env: env.clone(),
339+ };
340+ let steps: Vec<Map<String, Value>> = runs.get("steps").and_then(Value::as_array).map(|s| s.iter().filter_map(|s| s.as_object().cloned()).collect()).unwrap_or_default();
341+ let was_failed = self.failed;
342+ // A composite's steps see their own success, not the job's.
343+ self.failed = false;
344+ let mut ok = true;
345+ for step in &steps {
346+ if !self.step(&mut inner, step, 0, false, &Map::new()) {
347+ ok = false;
348+ }
349+ }
350+ let contexts = self.contexts_for(&inner, &inner.env.clone());
351+ let mut outputs = BTreeMap::new();
352+ if let Some(Value::Object(declared)) = action.get("outputs") {
353+ for (name, spec) in declared {
354+ if let Some(value) = spec.get("value") {
355+ let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
356+ outputs.insert(name.clone(), expr::to_text(&value));
357+ }
358+ }
359+ }
360+ self.failed = was_failed;
361+ return (ok, outputs);
362+ }
363+ if using == "docker" {
364+ self.log.line(&format!("##[error]`{uses}` is a Docker action, which does not run on g1t yet."));
365+ return (false, BTreeMap::new());
366+ }
367+ self.log.line(&format!("##[error]`{uses}` runs with `{using}`, which g1t does not know."));
368+ (false, BTreeMap::new())
369+ }
370+}
+4−1
1010 //! `update` brings a pull request up to date with its target branch,
1111 //! `review` has an agent review one, and `revise` sends the author back to
1212 //! address what the checks or a review found, `plan` turns an outcome
13−//! into issues, and `queue` builds and checks a state of the merge queue.
13+//! into issues, `queue` builds and checks a state of the merge queue, and
14+//! `actions` runs one job of a GitHub Actions workflow.
1415 //! See the modules of those names.
1516 //!
1617 //! Configuration comes from the environment:
2122 //! - `COMMIT_MESSAGE`: used if the agent leaves changes uncommitted.
2223 //! - `ANTHROPIC_API_KEY`: read by the harness itself.
2324
25+mod actions;
2426 mod checks;
2527 mod harness;
2628 mod plan;
174176 fn main() {
175177 // The same image does the other jobs a sandbox is started for.
176178 match std::env::var("MODE").as_deref() {
179+ Ok("actions") => std::process::exit(actions::main()),
177180 Ok("checks") => std::process::exit(checks::main()),
178181 Ok("update") => std::process::exit(update::main()),
179182 Ok("review") => std::process::exit(review::main()),
+18−0
1+[package]
2+name = "g1t-actions-service"
3+version = "0.1.0"
4+edition.workspace = true
5+license.workspace = true
6+description = "GitHub Actions workflows on g1t: what runs, its jobs and logs, secrets and variables."
7+
8+[lib]
9+crate-type = ["cdylib"]
10+
11+[dependencies]
12+g1t-actions.workspace = true
13+g1t-contracts.workspace = true
14+g1t-kit.workspace = true
15+g1t-secrets.workspace = true
16+serde.workspace = true
17+serde_json = { workspace = true, features = ["preserve_order"] }
18+worker.workspace = true
+138−0
1+-- GitHub Actions workflows on g1t: the workflows on each repository's
2+-- default branch, their runs and jobs, the jobs' logs, and the secrets and
3+-- variables they read. Every timestamp is RFC 3339 UTC.
4+
5+-- Workflows as they are on the default branch: for listing, schedules and
6+-- manual runs. Runs for pushes and pull requests read the file at their
7+-- own commit.
8+CREATE TABLE workflows (
9+ id TEXT PRIMARY KEY,
10+ repo_id TEXT NOT NULL,
11+ -- owner/name.
12+ repo TEXT NOT NULL,
13+ -- .github/workflows/ci.yml
14+ path TEXT NOT NULL,
15+ name TEXT NOT NULL,
16+ source TEXT NOT NULL,
17+ -- The events that start it, as a JSON array.
18+ events TEXT NOT NULL,
19+ -- Its schedules' cron lines, as a JSON array.
20+ crons TEXT NOT NULL DEFAULT '[]',
21+ error TEXT,
22+ -- active or disabled; kept when the file changes.
23+ state TEXT NOT NULL DEFAULT 'active',
24+ -- How many runs it has had, for run numbers.
25+ run_count INTEGER NOT NULL DEFAULT 0,
26+ updated_at TEXT NOT NULL,
27+ UNIQUE (repo_id, path)
28+);
29+CREATE INDEX workflows_scheduled ON workflows (state, crons);
30+
31+CREATE TABLE synced (
32+ repo_id TEXT PRIMARY KEY,
33+ at TEXT NOT NULL
34+);
35+
36+CREATE TABLE runs (
37+ id TEXT PRIMARY KEY,
38+ workflow_id TEXT NOT NULL,
39+ repo_id TEXT NOT NULL,
40+ repo TEXT NOT NULL,
41+ path TEXT NOT NULL,
42+ name TEXT NOT NULL,
43+ title TEXT NOT NULL,
44+ number INTEGER NOT NULL,
45+ attempt INTEGER NOT NULL DEFAULT 1,
46+ -- The GitHub event and activity type.
47+ event TEXT NOT NULL,
48+ action TEXT,
49+ git_ref TEXT NOT NULL,
50+ sha TEXT NOT NULL,
51+ pull INTEGER,
52+ -- pending (waiting for its concurrency group), queued, in_progress, completed.
53+ status TEXT NOT NULL,
54+ conclusion TEXT,
55+ -- Why the run could not start, such as a workflow file that does not read.
56+ error TEXT,
57+ actor TEXT,
58+ actor_id TEXT,
59+ -- The workflow file as of the run's commit.
60+ source TEXT NOT NULL,
61+ -- The github context's fields and the event's payload (RunInfo).
62+ info TEXT NOT NULL,
63+ -- workflow_dispatch inputs, as JSON.
64+ inputs TEXT NOT NULL DEFAULT '{}',
65+ -- 0 for a pull request from outside the workspace: its jobs get no
66+ -- secrets and no token that can write.
67+ trusted INTEGER NOT NULL DEFAULT 1,
68+ concurrency_group TEXT,
69+ -- What started it, so an event starts each workflow once.
70+ event_key TEXT NOT NULL,
71+ created_at TEXT NOT NULL,
72+ started_at TEXT,
73+ finished_at TEXT,
74+ UNIQUE (repo_id, path, event_key)
75+);
76+CREATE INDEX runs_by_repo ON runs (repo_id, id);
77+CREATE INDEX runs_by_workflow ON runs (workflow_id, id);
78+CREATE INDEX runs_by_status ON runs (status);
79+CREATE INDEX runs_by_group ON runs (repo_id, concurrency_group, status);
80+CREATE INDEX runs_by_sha ON runs (repo_id, sha);
81+
82+CREATE TABLE jobs (
83+ id TEXT PRIMARY KEY,
84+ run_id TEXT NOT NULL,
85+ repo_id TEXT NOT NULL,
86+ -- The workspace, for its limit on jobs running at once.
87+ namespace TEXT NOT NULL,
88+ -- Its key under jobs:, and which matrix combination it is (0 without one).
89+ key TEXT NOT NULL,
90+ ordinal INTEGER NOT NULL DEFAULT 0,
91+ name TEXT NOT NULL,
92+ needs TEXT NOT NULL DEFAULT '[]',
93+ -- The matrix combination, as JSON; null until it is expanded.
94+ matrix TEXT,
95+ -- waiting (for its needs), queued, in_progress, completed.
96+ status TEXT NOT NULL,
97+ conclusion TEXT,
98+ steps TEXT NOT NULL DEFAULT '[]',
99+ annotations TEXT NOT NULL DEFAULT '[]',
100+ outputs TEXT NOT NULL DEFAULT '{}',
101+ reason TEXT,
102+ token_hash TEXT,
103+ timeout_minutes INTEGER NOT NULL DEFAULT 60,
104+ -- continue-on-error: a failure that does not fail the run.
105+ continue_on_error INTEGER NOT NULL DEFAULT 0,
106+ -- strategy.max-parallel: how many of its matrix may run at once.
107+ max_parallel INTEGER,
108+ -- The last time its sandbox reported anything.
109+ seen_at TEXT,
110+ started_at TEXT,
111+ finished_at TEXT
112+);
113+CREATE INDEX jobs_by_run ON jobs (run_id, key, ordinal);
114+CREATE INDEX jobs_by_status ON jobs (status, namespace);
115+
116+CREATE TABLE logs (
117+ job_id TEXT NOT NULL,
118+ seq INTEGER NOT NULL,
119+ step INTEGER NOT NULL,
120+ text TEXT NOT NULL,
121+ PRIMARY KEY (job_id, seq)
122+);
123+
124+-- Secrets and variables, a repository's or a workspace's. A secret's value
125+-- is sealed, bound to its row's id.
126+CREATE TABLE settings (
127+ id TEXT PRIMARY KEY,
128+ -- repository or workspace.
129+ scope TEXT NOT NULL,
130+ -- The repository's id, or the workspace's slug.
131+ owner TEXT NOT NULL,
132+ -- secret or variable.
133+ kind TEXT NOT NULL,
134+ name TEXT NOT NULL,
135+ value TEXT NOT NULL,
136+ updated_at TEXT NOT NULL,
137+ UNIQUE (owner, kind, name)
138+);
+211−0
1+//! The actions service: a repository's GitHub Actions workflows, run on
2+//! g1t as they are. See `g1t_contracts::actions` for the methods and
3+//! `g1t_actions` for how workflows, expressions and filters are read.
4+//!
5+//! - [`sync`] reads workflow files, at the default branch for the list and
6+//! at an event's own commit for its runs.
7+//! - [`trigger`] turns events, schedules and manual runs into runs.
8+//! - [`plan`] moves a run's jobs along: each waits for the jobs it needs,
9+//! is skipped or expanded into its matrix, queued, started in a sandbox
10+//! when the workspace has room, and finished by the sandbox's report.
11+//! - [`payload`] builds the webhook-shaped `github.event`.
12+//! - [`settings`] keeps secrets and variables.
13+//!
14+//! The service acts as the repository's workspace: it reads what the
15+//! workspace can read, and a job's `GITHUB_TOKEN` is a short-lived token of
16+//! the workspace's.
17+
18+mod payload;
19+mod plan;
20+mod settings;
21+mod sync;
22+mod trigger;
23+mod views;
24+
25+use g1t_contracts::events::Event;
26+use g1t_contracts::identity::{SlugArgs, Workspace};
27+use g1t_contracts::repos::{GetArgs, GetByIdArgs, Repo, RepoPath};
28+use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, Viewer};
29+use g1t_kit::{args, reply, rpc_method};
30+use g1t_secrets::Sealer;
31+use serde::Deserialize;
32+use serde_json::Value;
33+use worker::wasm_bindgen::JsValue;
34+use worker::{Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
35+
36+/// The most workflow files read from a repository.
37+pub const MAX_WORKFLOWS: usize = 50;
38+/// Jobs one workspace may have running at once; the rest wait their turn.
39+pub const RUNNING_PER_WORKSPACE: u32 = 4;
40+/// The longest a job may run, whatever its `timeout-minutes`.
41+pub const MAX_TIMEOUT_MINUTES: u32 = 60;
42+/// A running job that has said nothing for this long is taken as lost.
43+pub const SILENT_MS: u64 = 10 * 60 * 1000;
44+pub const SITE: &str = "https://g1t.sh";
45+pub const API: &str = "https://api.g1t.sh";
46+
47+#[derive(Deserialize)]
48+struct Count {
49+ n: u32,
50+}
51+
52+pub fn optional(value: Option<&str>) -> JsValue {
53+ value.map_or(JsValue::NULL, JsValue::from)
54+}
55+
56+pub fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
57+ Outcome::fail(code, message)
58+}
59+
60+/// `owner/name` as a path.
61+pub fn repo_path(full_name: &str) -> RepoPath {
62+ let (namespace, name) = full_name.split_once('/').unwrap_or((full_name, ""));
63+ RepoPath {
64+ namespace: namespace.to_owned(),
65+ name: name.to_owned(),
66+ }
67+}
68+
69+pub struct Actions {
70+ db: D1Database,
71+ repos: Fetcher,
72+ work: Fetcher,
73+ identity: Fetcher,
74+ runner: Fetcher,
75+ /// Seals secrets; absent until `ACTIONS_KEY` is set, when secrets
76+ /// cannot be saved.
77+ sealer: Option<Sealer>,
78+}
79+
80+impl Actions {
81+ fn new(env: &Env) -> Result<Self> {
82+ Ok(Actions {
83+ db: env.d1("DB")?,
84+ repos: env.service("REPOS")?,
85+ work: env.service("WORK")?,
86+ identity: env.service("IDENTITY")?,
87+ runner: env.service("RUNNER")?,
88+ sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())),
89+ })
90+ }
91+
92+ /// The workspace itself, as the service acts.
93+ async fn workspace_actor(&self, slug: &str) -> Result<Option<User>> {
94+ let workspace: Option<Workspace> = g1t_kit::call(&self.identity, "get_workspace", &SlugArgs { slug: slug.to_owned() }).await?;
95+ Ok(workspace.map(|workspace| User {
96+ id: workspace.id,
97+ username: workspace.slug.clone(),
98+ kind: PrincipalKind::Workspace,
99+ verified: true,
100+ workspaces: vec![Membership {
101+ slug: workspace.slug,
102+ role: Role::Member,
103+ }],
104+ }))
105+ }
106+
107+ /// The repository, if the viewer may see it and it is not a pull
108+ /// request's working copy.
109+ async fn visible_repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Option<Repo>> {
110+ let found: Outcome<Repo> = g1t_kit::call(
111+ &self.repos,
112+ "get",
113+ &GetArgs {
114+ path: path.clone(),
115+ viewer: viewer.clone(),
116+ },
117+ )
118+ .await?;
119+ Ok(found.into_result().ok().filter(|repo| repo.fork_of.is_none()))
120+ }
121+
122+ /// A repository by id, as its workspace sees it.
123+ async fn repo_by_id(&self, id: &str) -> Result<Option<(Repo, User)>> {
124+ let path: Option<RepoPath> = g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: id.to_owned() }).await?;
125+ let Some(path) = path else { return Ok(None) };
126+ let Some(actor) = self.workspace_actor(&path.namespace).await? else {
127+ return Ok(None);
128+ };
129+ let found: Outcome<Repo> = g1t_kit::call(
130+ &self.repos,
131+ "get_by_id",
132+ &GetByIdArgs {
133+ id: id.to_owned(),
134+ viewer: Some(actor.clone()),
135+ },
136+ )
137+ .await?;
138+ Ok(found.into_result().ok().filter(|repo| repo.fork_of.is_none()).map(|repo| (repo, actor)))
139+ }
140+
141+ /// Refuses anyone but a member of the repository's workspace.
142+ fn member(actor: &User, repo: &RepoPath) -> Option<Outcome<()>> {
143+ (actor.kind == PrincipalKind::Agent || !actor.is_member(&repo.namespace.to_lowercase()))
144+ .then(|| fail(FailureCode::Forbidden, format!("Only members of {} can do that.", repo.namespace)))
145+ }
146+}
147+
148+/// Unwraps an `Outcome`, or returns its failure from the enclosing method.
149+#[macro_export]
150+macro_rules! check {
151+ ($outcome:expr) => {
152+ match $outcome {
153+ g1t_contracts::Outcome::Ok(value) => value,
154+ g1t_contracts::Outcome::Fail(refused) => return Ok(g1t_contracts::Outcome::Fail(refused)),
155+ }
156+ };
157+}
158+
159+#[event(fetch)]
160+async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
161+ let Some(method) = rpc_method(&request) else {
162+ return Response::error("Not found", 404);
163+ };
164+ let body: Value = request.json().await?;
165+ let service = Actions::new(&env)?;
166+ match method.as_str() {
167+ "workflows" => reply(&service.workflows(args(body)?).await?),
168+ "runs" => reply(&service.runs(args(body)?).await?),
169+ "run" => reply(&service.run(args(body)?).await?),
170+ "logs" => reply(&service.logs(args(body)?).await?),
171+ "dispatch" => reply(&service.dispatch(args(body)?).await?),
172+ "cancel" => reply(&service.cancel(args(body)?).await?),
173+ "rerun" => reply(&service.rerun(args(body)?).await?),
174+ "set_workflow_enabled" => reply(&service.set_workflow_enabled(args(body)?).await?),
175+ "settings" => reply(&service.settings(args(body)?).await?),
176+ "set_setting" => reply(&service.set_setting(args(body)?).await?),
177+ "delete_setting" => reply(&service.delete_setting(args(body)?).await?),
178+ "job_spec" => reply(&service.job_spec(args(body)?).await?),
179+ "job_report" => reply(&service.job_report(args(body)?).await?),
180+ _ => Response::error("Unknown method", 404),
181+ }
182+}
183+
184+/// Events from the bus, on this service's own queue.
185+#[event(queue)]
186+async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> {
187+ let service = Actions::new(&env)?;
188+ for message in batch.messages()? {
189+ if let Err(error) = service.on_event(message.body()).await {
190+ worker::console_error!("actions: event {} failed: {error}", message.body().id);
191+ message.retry();
192+ continue;
193+ }
194+ message.ack();
195+ }
196+ Ok(())
197+}
198+
199+/// Every minute: schedules that fire, jobs waiting for room, and jobs
200+/// whose sandbox went quiet.
201+#[event(scheduled)]
202+async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
203+ match Actions::new(&env) {
204+ Ok(service) => {
205+ if let Err(error) = service.on_minute(g1t_kit::now_ms()).await {
206+ worker::console_error!("actions: the sweep failed: {error}");
207+ }
208+ }
209+ Err(error) => worker::console_error!("actions: could not start: {error}"),
210+ }
211+}
+178−0
1+//! `github.event`: the webhook-shaped payload GitHub gives a workflow,
2+//! built from g1t's own records so `github.event.pull_request.number`,
3+//! `github.event.issue.labels.*.name` and the like read as they do there.
4+
5+use g1t_contracts::User;
6+use g1t_contracts::repos::{Commit, Repo};
7+use g1t_contracts::work::{Comment, Issue, Pull, PullStatus, State};
8+use serde_json::{Value, json};
9+
10+use crate::{API, SITE};
11+
12+pub fn repository(repo: &Repo) -> Value {
13+ let full_name = format!("{}/{}", repo.namespace, repo.name);
14+ json!({
15+ "id": repo.id,
16+ "node_id": repo.id,
17+ "name": repo.name,
18+ "full_name": full_name,
19+ "private": repo.is_private,
20+ "owner": { "login": repo.namespace, "type": "Organization" },
21+ "html_url": format!("{SITE}/{full_name}"),
22+ "url": format!("{API}/repos/{full_name}"),
23+ "clone_url": format!("{SITE}/{full_name}.git"),
24+ "description": repo.description,
25+ "default_branch": repo.default_branch,
26+ "fork": false,
27+ "visibility": if repo.is_private { "private" } else { "public" },
28+ })
29+}
30+
31+pub fn user(login: &str) -> Value {
32+ json!({ "login": login, "type": "User", "html_url": format!("{SITE}/{login}") })
33+}
34+
35+fn person(user: &User) -> Value {
36+ self::user(&user.username)
37+}
38+
39+fn labels(names: &[String]) -> Value {
40+ Value::Array(names.iter().map(|name| json!({ "name": name })).collect())
41+}
42+
43+pub fn commit(repo: &Repo, commit: &Commit) -> Value {
44+ json!({
45+ "id": commit.hash,
46+ "tree_id": commit.tree_hash,
47+ "message": commit.message,
48+ "timestamp": commit.authored_at,
49+ "url": format!("{SITE}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash),
50+ "author": { "name": commit.author.name, "email": commit.author.email },
51+ "committer": { "name": commit.author.name, "email": commit.author.email },
52+ "distinct": true,
53+ })
54+}
55+
56+pub fn push(repo: &Repo, git_ref: &str, before: Option<&str>, after: &str, commits: &[Commit], pusher: &str) -> Value {
57+ let zero = "0000000000000000000000000000000000000000";
58+ let commits: Vec<Value> = commits.iter().map(|c| commit(repo, c)).collect();
59+ json!({
60+ "ref": git_ref,
61+ "before": before.unwrap_or(zero),
62+ "after": after,
63+ "created": before.is_none(),
64+ "deleted": false,
65+ "forced": false,
66+ "base_ref": null,
67+ "compare": format!("{SITE}/{}/{}/commit/{after}", repo.namespace, repo.name),
68+ "head_commit": commits.first().cloned().unwrap_or(Value::Null),
69+ "commits": commits,
70+ "pusher": { "name": pusher, "email": null },
71+ "repository": repository(repo),
72+ "sender": user(pusher),
73+ })
74+}
75+
76+pub fn issue(repo: &Repo, issue: &Issue) -> Value {
77+ let full_name = format!("{}/{}", repo.namespace, repo.name);
78+ json!({
79+ "id": issue.id,
80+ "number": issue.number,
81+ "title": issue.title,
82+ "body": issue.body,
83+ "state": if issue.state == State::Open { "open" } else { "closed" },
84+ "state_reason": issue.reason,
85+ "labels": labels(&issue.labels),
86+ "user": person(&issue.author),
87+ "assignees": issue.assignees.iter().map(|a| user(a)).collect::<Vec<_>>(),
88+ "comments": issue.comment_count,
89+ "created_at": issue.created_at,
90+ "updated_at": issue.updated_at,
91+ "closed_at": issue.closed_at,
92+ "html_url": format!("{SITE}/{full_name}/issues/{}", issue.number),
93+ "url": format!("{API}/repos/{full_name}/issues/{}", issue.number),
94+ })
95+}
96+
97+/// A pull request. `labels` are its issue's, since g1t labels issues.
98+pub fn pull(repo: &Repo, pull: &Pull, labels_of: &[String]) -> Value {
99+ let full_name = format!("{}/{}", repo.namespace, repo.name);
100+ let head_ref = head_ref(pull);
101+ let head_repo = match &pull.fork {
102+ Some(fork) => json!({ "full_name": format!("{}/{}", fork.namespace, fork.name), "fork": true }),
103+ None => json!({ "full_name": full_name, "fork": false }),
104+ };
105+ json!({
106+ "id": pull.id,
107+ "number": pull.number,
108+ "title": pull.title,
109+ "body": pull.body,
110+ "state": if pull.status.is_active() { "open" } else { "closed" },
111+ "draft": pull.status == PullStatus::Draft,
112+ "merged": pull.status == PullStatus::Merged,
113+ "merged_at": pull.merged_at,
114+ "merged_by": pull.merged_by.as_deref().map(user),
115+ "merge_commit_sha": if pull.status == PullStatus::Merged { pull.head_commit.clone() } else { None },
116+ "labels": labels(labels_of),
117+ "user": person(&pull.author),
118+ "assignees": pull.assignees.iter().map(|a| user(a)).collect::<Vec<_>>(),
119+ "requested_reviewers": pull.reviewers.iter().map(|r| user(r)).collect::<Vec<_>>(),
120+ "head": {
121+ "ref": head_ref,
122+ "sha": pull.head_commit,
123+ "label": format!("{}:{head_ref}", repo.namespace),
124+ "repo": head_repo,
125+ },
126+ "base": {
127+ "ref": repo.default_branch,
128+ "sha": pull.merge_base,
129+ "label": format!("{}:{}", repo.namespace, repo.default_branch),
130+ "repo": repository(repo),
131+ },
132+ "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
133+ "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
134+ "issue_url": pull.issue.map(|n| format!("{API}/repos/{full_name}/issues/{n}")),
135+ })
136+}
137+
138+/// The branch a pull request comes from, or a name for its fork.
139+pub fn head_ref(pull: &Pull) -> String {
140+ pull.branch.clone().unwrap_or_else(|| format!("pull/{}", pull.number))
141+}
142+
143+pub fn comment(repo: &Repo, number: u32, comment: &Comment, on_pull: bool) -> Value {
144+ let full_name = format!("{}/{}", repo.namespace, repo.name);
145+ let page = if on_pull { "pull" } else { "issues" };
146+ json!({
147+ "id": comment.id,
148+ "body": comment.body,
149+ "user": person(&comment.author),
150+ "created_at": comment.created_at,
151+ "updated_at": comment.created_at,
152+ "path": comment.path,
153+ "line": comment.line,
154+ "html_url": format!("{SITE}/{full_name}/{page}/{number}#{}", comment.id),
155+ })
156+}
157+
158+/// An issue as `issue_comment` gives it for a pull request: the pull
159+/// request's number and title, with `pull_request` set.
160+pub fn pull_as_issue(repo: &Repo, pull: &Pull, labels_of: &[String]) -> Value {
161+ let full_name = format!("{}/{}", repo.namespace, repo.name);
162+ json!({
163+ "id": pull.id,
164+ "number": pull.number,
165+ "title": pull.title,
166+ "body": pull.body,
167+ "state": if pull.status.is_active() { "open" } else { "closed" },
168+ "labels": labels(labels_of),
169+ "user": person(&pull.author),
170+ "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
171+ "pull_request": {
172+ "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
173+ "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
174+ "merged_at": pull.merged_at,
175+ },
176+ })
177+}
178+
+1211−0
1+//! 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+
6+use g1t_actions::events::{RunInfo, runner_context};
7+use g1t_actions::expr::{self, Scope, Status};
8+use g1t_actions::matrix;
9+use g1t_actions::workflow::{self, Workflow};
10+use g1t_contracts::actions::{JobCallArgs, RunActionArgs, StartJobArgs, WorkflowRun};
11+use g1t_contracts::identity::{CreateAccessTokenArgs, CreatedAccessToken};
12+use g1t_contracts::repos::{Repo, RepoPath};
13+use g1t_contracts::time::rfc3339;
14+use g1t_contracts::{FailureCode, Outcome, new_id};
15+use g1t_kit::now_ms;
16+use g1t_secrets::{random_hex, same, sha256_hex};
17+use serde::Deserialize;
18+use serde_json::{Map, Value, json};
19+use worker::Result;
20+
21+use crate::sync::WorkflowRow;
22+use crate::{Actions, Count, MAX_TIMEOUT_MINUTES, RUNNING_PER_WORKSPACE, SILENT_MS, SITE, check, fail, optional, repo_path};
23+
24+/// The most log one job keeps, in bytes; past it, the log says so and stops.
25+const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
26+/// The most a single log report may add.
27+const MAX_CHUNK_BYTES: usize = 256 * 1024;
28+const MAX_ANNOTATIONS: usize = 50;
29+
30+pub struct NewRun {
31+ pub repo: Repo,
32+ pub path: String,
33+ pub source: String,
34+ pub workflow: Workflow,
35+ pub info: RunInfo,
36+ pub action: Option<String>,
37+ pub pull: Option<u32>,
38+ pub title: String,
39+ pub inputs: Map<String, Value>,
40+ pub event_key: String,
41+ pub actor_id: Option<String>,
42+ pub actor: Option<String>,
43+ pub trusted: bool,
44+}
45+
46+#[derive(Clone, Deserialize)]
47+pub struct RunRow {
48+ pub id: String,
49+ pub workflow_id: String,
50+ pub repo_id: String,
51+ pub repo: String,
52+ pub path: String,
53+ pub name: String,
54+ pub title: String,
55+ pub number: u64,
56+ pub attempt: u64,
57+ pub event: String,
58+ pub action: Option<String>,
59+ pub git_ref: String,
60+ pub sha: String,
61+ pub pull: Option<u32>,
62+ pub status: String,
63+ pub conclusion: Option<String>,
64+ pub error: Option<String>,
65+ pub actor: Option<String>,
66+ pub actor_id: Option<String>,
67+ pub source: String,
68+ pub info: String,
69+ pub inputs: String,
70+ pub trusted: u32,
71+ pub concurrency_group: Option<String>,
72+ pub created_at: String,
73+ pub started_at: Option<String>,
74+ pub finished_at: Option<String>,
75+}
76+
77+impl RunRow {
78+ pub fn info(&self) -> RunInfo {
79+ let mut info: RunInfo = serde_json::from_str(&self.info).unwrap_or_default();
80+ info.run_id = self.id.clone();
81+ info.run_number = self.number;
82+ info.run_attempt = self.attempt;
83+ info.workflow_path = self.path.clone();
84+ if info.workflow.is_empty() {
85+ info.workflow = self.name.clone();
86+ }
87+ info
88+ }
89+
90+ pub fn inputs(&self) -> Map<String, Value> {
91+ serde_json::from_str(&self.inputs).unwrap_or_default()
92+ }
93+
94+ pub fn summary(&self) -> WorkflowRun {
95+ WorkflowRun {
96+ id: self.id.clone(),
97+ workflow_id: self.workflow_id.clone(),
98+ path: self.path.clone(),
99+ name: self.name.clone(),
100+ title: self.title.clone(),
101+ number: self.number,
102+ attempt: self.attempt,
103+ event: self.event.clone(),
104+ git_ref: self.git_ref.clone(),
105+ sha: self.sha.clone(),
106+ pull: self.pull,
107+ status: self.status.clone(),
108+ conclusion: self.conclusion.clone(),
109+ error: self.error.clone(),
110+ actor: self.actor.clone(),
111+ created_at: self.created_at.clone(),
112+ started_at: self.started_at.clone(),
113+ finished_at: self.finished_at.clone(),
114+ }
115+ }
116+}
117+
118+#[derive(Clone, Deserialize)]
119+pub struct JobRow {
120+ pub id: String,
121+ pub run_id: String,
122+ pub repo_id: String,
123+ pub namespace: String,
124+ pub key: String,
125+ pub ordinal: u32,
126+ pub name: String,
127+ pub needs: String,
128+ pub matrix: Option<String>,
129+ pub status: String,
130+ pub conclusion: Option<String>,
131+ pub steps: String,
132+ pub annotations: String,
133+ pub outputs: String,
134+ pub reason: Option<String>,
135+ pub token_hash: Option<String>,
136+ pub timeout_minutes: u32,
137+ pub continue_on_error: u32,
138+ pub max_parallel: Option<u32>,
139+ pub seen_at: Option<String>,
140+ pub started_at: Option<String>,
141+ pub finished_at: Option<String>,
142+}
143+
144+impl JobRow {
145+ pub fn needs(&self) -> Vec<String> {
146+ serde_json::from_str(&self.needs).unwrap_or_default()
147+ }
148+}
149+
150+/// What the jobs of one key came to, for `needs.<key>`.
151+fn key_result(rows: &[&JobRow]) -> &'static str {
152+ let failed = |row: &&&JobRow| row.conclusion.as_deref() == Some("failure") && row.continue_on_error == 0;
153+ if rows.iter().any(|row| failed(&row)) {
154+ "failure"
155+ } else if rows.iter().any(|row| row.conclusion.as_deref() == Some("cancelled")) {
156+ "cancelled"
157+ } else if rows.iter().all(|row| row.conclusion.as_deref() == Some("skipped")) {
158+ "skipped"
159+ } else {
160+ "success"
161+ }
162+}
163+
164+fn now() -> String {
165+ rfc3339(now_ms())
166+}
167+
168+impl Actions {
169+ pub async fn run_row(&self, id: &str) -> Result<Option<RunRow>> {
170+ self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<RunRow>(None).await
171+ }
172+
173+ pub async fn job_rows(&self, run_id: &str) -> Result<Vec<JobRow>> {
174+ self.db
175+ .prepare("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid")
176+ .bind(&[run_id.into()])?
177+ .all()
178+ .await?
179+ .results::<JobRow>()
180+ }
181+
182+ pub async fn run_summary(&self, id: &str) -> Result<Outcome<WorkflowRun>> {
183+ Ok(match self.run_row(id).await? {
184+ Some(row) => Outcome::Ok(row.summary()),
185+ None => fail(FailureCode::NotFound, "No such run."),
186+ })
187+ }
188+
189+ /// The contexts every expression outside a job's steps may use.
190+ fn base_contexts(run: &RunRow, vars: &Map<String, Value>, job: &str) -> Map<String, Value> {
191+ let mut contexts = Map::new();
192+ contexts.insert("github".into(), run.info().context(job, "", run.action.as_deref()));
193+ contexts.insert("inputs".into(), Value::Object(run.inputs()));
194+ contexts.insert("vars".into(), Value::Object(vars.clone()));
195+ contexts.insert("needs".into(), json!({}));
196+ contexts.insert("runner".into(), runner_context());
197+ contexts
198+ }
199+
200+ /// Makes a run and its jobs, and starts what can start. `None` when the
201+ /// event already started this workflow.
202+ pub async fn create_run(&self, new: NewRun) -> Result<Option<String>> {
203+ let workflow_row = self.workflow_row(&new.repo, &new.path, &new.workflow.display_name(&new.path), &new.source).await?;
204+ let numbered = self
205+ .db
206+ .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n")
207+ .bind(&[workflow_row.id.as_str().into()])?
208+ .first::<Count>(None)
209+ .await?
210+ .map_or(1, |count| count.n);
211+ let id = new_id("run", now_ms());
212+ let mut info = new.info.clone();
213+ info.workflow = new.workflow.display_name(&new.path);
214+ info.workflow_path = new.path.clone();
215+ info.run_id = id.clone();
216+ info.run_number = u64::from(numbered);
217+ let vars = self.variables_for(&new.repo.id, &new.repo.namespace).await?;
218+
219+ // run-name and the concurrency group read github, inputs and vars.
220+ let mut contexts = Map::new();
221+ contexts.insert("github".into(), info.context("", "", new.action.as_deref()));
222+ contexts.insert("inputs".into(), Value::Object(new.inputs.clone()));
223+ contexts.insert("vars".into(), Value::Object(vars));
224+ let scope = Scope {
225+ contexts: &contexts,
226+ status: Status::Success,
227+ hash_files: None,
228+ };
229+ let title = new
230+ .workflow
231+ .run_name
232+ .as_deref()
233+ .and_then(|run_name| expr::interpolate(run_name, &scope).ok())
234+ .filter(|title| !title.trim().is_empty())
235+ .unwrap_or(new.title.clone());
236+ let group = new.workflow.concurrency.as_ref().and_then(|c| expr::interpolate(&c.group, &scope).ok());
237+ let cancel_in_progress = new
238+ .workflow
239+ .concurrency
240+ .as_ref()
241+ .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok())
242+ .is_some_and(|value| expr::truthy(&value));
243+
244+ let inserted = self
245+ .db
246+ .prepare(
247+ "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, action, git_ref, sha,
248+ pull, status, actor, actor_id, source, info, inputs, trusted, concurrency_group, event_key, created_at)
249+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
250+ )
251+ .bind(&[
252+ id.as_str().into(),
253+ workflow_row.id.as_str().into(),
254+ new.repo.id.as_str().into(),
255+ format!("{}/{}", new.repo.namespace, new.repo.name).into(),
256+ new.path.as_str().into(),
257+ info.workflow.as_str().into(),
258+ title.as_str().into(),
259+ numbered.into(),
260+ info.event_name.as_str().into(),
261+ optional(new.action.as_deref()),
262+ info.git_ref.as_str().into(),
263+ info.sha.as_str().into(),
264+ new.pull.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
265+ optional(new.actor.as_deref()),
266+ optional(new.actor_id.as_deref()),
267+ new.source.as_str().into(),
268+ serde_json::to_string(&info)?.into(),
269+ serde_json::to_string(&new.inputs)?.into(),
270+ u32::from(new.trusted).into(),
271+ optional(group.as_deref()),
272+ new.event_key.as_str().into(),
273+ now().into(),
274+ ])?
275+ .first::<Value>(None)
276+ .await?;
277+ if inserted.is_none() {
278+ return Ok(None);
279+ }
280+ if let Some(run) = self.run_row(&id).await? {
281+ self.report_pending(&run).await?;
282+ }
283+
284+ // Every job, waiting; each is expanded when the jobs it needs are done.
285+ let mut statements = Vec::new();
286+ for job in &new.workflow.jobs {
287+ statements.push(
288+ self.db
289+ .prepare(
290+ "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting')",
291+ )
292+ .bind(&[
293+ new_id("job", now_ms()).into(),
294+ id.as_str().into(),
295+ new.repo.id.as_str().into(),
296+ new.repo.namespace.as_str().into(),
297+ job.id.as_str().into(),
298+ job.name.clone().filter(|n| !expr::has_expression(n)).unwrap_or(job.id.clone()).into(),
299+ serde_json::to_string(&job.needs)?.into(),
300+ ])?,
301+ );
302+ }
303+ self.db.batch(statements).await?;
304+
305+ // One run at a time per concurrency group.
306+ if let Some(group) = &group {
307+ let others = self
308+ .db
309+ .prepare("SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND id != ? AND status != 'completed' ORDER BY id")
310+ .bind(&[new.repo.id.as_str().into(), group.as_str().into(), id.as_str().into()])?
311+ .all()
312+ .await?
313+ .results::<RunRow>()?;
314+ for other in &others {
315+ if cancel_in_progress || other.status == "pending" {
316+ // A newer run replaces a waiting one, as on GitHub.
317+ self.cancel_run(other, "A newer run in the same concurrency group replaced it.").await?;
318+ }
319+ }
320+ if !cancel_in_progress && others.iter().any(|other| other.status != "pending") {
321+ self.db
322+ .prepare("UPDATE runs SET status = 'pending' WHERE id = ?")
323+ .bind(&[id.as_str().into()])?
324+ .run()
325+ .await?;
326+ return Ok(Some(id));
327+ }
328+ }
329+ self.advance(&id).await?;
330+ Ok(Some(id))
331+ }
332+
333+ /// A run that could not start, such as for a workflow file that does not read.
334+ #[allow(clippy::too_many_arguments)]
335+ pub async fn record_failed_run(
336+ &self,
337+ row: &WorkflowRow,
338+ git_ref: &str,
339+ sha: &str,
340+ event_key: &str,
341+ actor_id: Option<&str>,
342+ actor: &str,
343+ problem: &str,
344+ ) -> Result<()> {
345+ let numbered = self
346+ .db
347+ .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n")
348+ .bind(&[row.id.as_str().into()])?
349+ .first::<Count>(None)
350+ .await?
351+ .map_or(1, |count| count.n);
352+ let at = now();
353+ self.db
354+ .prepare(
355+ "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, git_ref, sha, status,
356+ conclusion, error, actor, actor_id, source, info, event_key, created_at, finished_at)
357+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'push', ?, ?, 'completed', 'failure', ?, ?, ?, ?, '{}', ?, ?, ?)",
358+ )
359+ .bind(&[
360+ new_id("run", now_ms()).into(),
361+ row.id.as_str().into(),
362+ row.repo_id.as_str().into(),
363+ row.repo.as_str().into(),
364+ row.path.as_str().into(),
365+ row.path.as_str().into(),
366+ "Invalid workflow file".into(),
367+ numbered.into(),
368+ git_ref.into(),
369+ sha.into(),
370+ problem.into(),
371+ actor.into(),
372+ optional(actor_id),
373+ row.source.as_str().into(),
374+ event_key.into(),
375+ at.as_str().into(),
376+ at.as_str().into(),
377+ ])?
378+ .run()
379+ .await?;
380+ Ok(())
381+ }
382+
383+ /// Moves a run along: jobs whose needs are done are decided on, and
384+ /// jobs that can start are started.
385+ pub async fn advance(&self, run_id: &str) -> Result<()> {
386+ // Each pass may finish jobs (skipped ones), which may free others.
387+ for _ in 0..20 {
388+ let Some(run) = self.run_row(run_id).await? else { return Ok(()) };
389+ if run.status == "completed" || run.status == "pending" {
390+ return Ok(());
391+ }
392+ let workflow = match workflow::parse(&run.source) {
393+ Ok(workflow) => workflow,
394+ Err(problem) => {
395+ self.finish_run(&run, Some(&problem)).await?;
396+ return Ok(());
397+ }
398+ };
399+ let jobs = self.job_rows(run_id).await?;
400+ let mut changed = false;
401+ for job in &workflow.jobs {
402+ let rows: Vec<&JobRow> = jobs.iter().filter(|row| row.key == job.id).collect();
403+ if rows.is_empty() || !rows.iter().all(|row| row.status == "waiting") {
404+ continue;
405+ }
406+ let needed: Vec<(&String, Vec<&JobRow>)> =
407+ job.needs.iter().map(|need| (need, jobs.iter().filter(|row| &row.key == need).collect())).collect();
408+ if !needed.iter().all(|(_, rows)| rows.iter().all(|row| row.status == "completed")) {
409+ continue;
410+ }
411+ self.decide(&run, job, rows[0], &needed).await?;
412+ changed = true;
413+ }
414+ if !changed {
415+ break;
416+ }
417+ }
418+ self.start_queued().await?;
419+ self.finish_if_done(run_id).await
420+ }
421+
422+ /// Decides on one job whose needs are done: skip it, fail it, or expand
423+ /// it into its matrix and queue it.
424+ async fn decide(&self, run: &RunRow, job: &workflow::Job, row: &JobRow, needed: &[(&String, Vec<&JobRow>)]) -> Result<()> {
425+ let vars = self.variables_for(&run.repo_id, &repo_path(&run.repo).namespace).await?;
426+ let mut contexts = Self::base_contexts(run, &vars, &job.id);
427+ let mut needs = Map::new();
428+ let mut status = if run.conclusion.as_deref() == Some("cancelled") { Status::Cancelled } else { Status::Success };
429+ for (key, rows) in needed {
430+ let result = key_result(rows);
431+ let mut outputs = Map::new();
432+ for row in rows {
433+ if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) {
434+ outputs.extend(more);
435+ }
436+ }
437+ if result != "success" && status == Status::Success {
438+ status = Status::Failure;
439+ }
440+ needs.insert((*key).clone(), json!({ "result": result, "outputs": outputs }));
441+ }
442+ contexts.insert("needs".into(), Value::Object(needs));
443+ let scope = Scope {
444+ contexts: &contexts,
445+ status,
446+ hash_files: None,
447+ };
448+ let condition = job.condition.as_deref().unwrap_or_default();
449+ match expr::condition(condition, &scope) {
450+ Ok(true) => {}
451+ Ok(false) => return self.skip_job(row, None).await,
452+ Err(problem) => return self.fail_job(row, &format!("Its `if` does not read: {problem}")).await,
453+ }
454+ if job.uses.is_some() {
455+ return self.fail_job(row, "Reusable workflows (`uses:` on a job) are not called on g1t yet.").await;
456+ }
457+
458+ // Its matrix, which may come from a needed job's outputs.
459+ let combinations = match &job.matrix {
460+ None => vec![Map::new()],
461+ Some(matrix) => {
462+ let value = match expr::interpolate_value(matrix, &scope) {
463+ Ok(value) => value,
464+ Err(problem) => return self.fail_job(row, &format!("Its matrix does not read: {problem}")).await,
465+ };
466+ match matrix::expand(&value) {
467+ Ok(combinations) if !combinations.is_empty() => combinations,
468+ Ok(_) => return self.fail_job(row, "Its matrix makes no jobs.").await,
469+ Err(problem) => return self.fail_job(row, &problem).await,
470+ }
471+ }
472+ };
473+ let total = combinations.len();
474+ let raw = job.raw.as_object().cloned().unwrap_or_default();
475+ let mut statements = Vec::new();
476+ for (index, combination) in combinations.iter().enumerate() {
477+ let mut contexts = contexts.clone();
478+ contexts.insert("matrix".into(), Value::Object(combination.clone()));
479+ contexts.insert(
480+ "strategy".into(),
481+ json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }),
482+ );
483+ let scope = Scope {
484+ contexts: &contexts,
485+ status: Status::Success,
486+ hash_files: None,
487+ };
488+ let base_name = job.name.clone().unwrap_or(job.id.clone());
489+ let name = if expr::has_expression(&base_name) {
490+ expr::interpolate(&base_name, &scope).unwrap_or(base_name)
491+ } else if job.matrix.is_some() {
492+ matrix::job_name(&base_name, combination)
493+ } else {
494+ base_name
495+ };
496+ let runs_on = expr::interpolate_value(&job.runs_on, &scope).unwrap_or(Value::Null);
497+ let labels = match &runs_on {
498+ Value::String(label) => vec![label.clone()],
499+ Value::Array(labels) => labels.iter().map(expr::to_text).collect(),
500+ Value::Object(spec) => spec.get("labels").map(|l| match l {
501+ Value::Array(labels) => labels.iter().map(expr::to_text).collect(),
502+ other => vec![expr::to_text(other)],
503+ }).unwrap_or_default(),
504+ _ => Vec::new(),
505+ };
506+ let reason = labels
507+ .iter()
508+ .find(|label| {
509+ let lower = label.to_ascii_lowercase();
510+ lower.contains("windows") || lower.contains("macos")
511+ })
512+ .map(|label| format!("`runs-on: {label}`: g1t runs jobs on Linux only."));
513+ let timeout = raw
514+ .get("timeout-minutes")
515+ .and_then(|value| expr::interpolate_value(value, &scope).ok())
516+ .and_then(|value| value.as_f64().or_else(|| expr::to_text(&value).parse().ok()))
517+ .map_or(MAX_TIMEOUT_MINUTES, |minutes| (minutes.ceil() as u32).clamp(1, MAX_TIMEOUT_MINUTES));
518+ let continue_on_error = raw
519+ .get("continue-on-error")
520+ .and_then(|value| expr::interpolate_value(value, &scope).ok())
521+ .is_some_and(|value| expr::truthy(&value));
522+ let (status, conclusion, finished) = match &reason {
523+ Some(_) => ("completed", Some("failure"), Some(now())),
524+ None => ("queued", None, None),
525+ };
526+ let values: Vec<worker::wasm_bindgen::JsValue> = vec![
527+ name.into(),
528+ serde_json::to_string(combination)?.into(),
529+ status.into(),
530+ optional(conclusion),
531+ optional(reason.as_deref()),
532+ timeout.into(),
533+ u32::from(continue_on_error).into(),
534+ job.max_parallel.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
535+ optional(finished.as_deref()),
536+ ];
537+ if index == 0 {
538+ let mut bound = values;
539+ bound.push(row.id.as_str().into());
540+ statements.push(
541+ self.db
542+ .prepare(
543+ "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, timeout_minutes = ?,
544+ continue_on_error = ?, max_parallel = ?, finished_at = ? WHERE id = ?",
545+ )
546+ .bind(&bound)?,
547+ );
548+ } else {
549+ let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![
550+ new_id("job", now_ms()).into(),
551+ row.run_id.as_str().into(),
552+ row.repo_id.as_str().into(),
553+ row.namespace.as_str().into(),
554+ row.key.as_str().into(),
555+ (index as u32).into(),
556+ row.needs.as_str().into(),
557+ ];
558+ bound.extend(values);
559+ statements.push(
560+ self.db
561+ .prepare(
562+ "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, name, matrix, status, conclusion, reason,
563+ timeout_minutes, continue_on_error, max_parallel, finished_at)
564+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
565+ )
566+ .bind(&bound)?,
567+ );
568+ }
569+ }
570+ self.db.batch(statements).await?;
571+ Ok(())
572+ }
573+
574+ async fn skip_job(&self, row: &JobRow, reason: Option<&str>) -> Result<()> {
575+ self.db
576+ .prepare("UPDATE jobs SET status = 'completed', conclusion = 'skipped', reason = ?, finished_at = ? WHERE id = ?")
577+ .bind(&[optional(reason), now().into(), row.id.as_str().into()])?
578+ .run()
579+ .await?;
580+ Ok(())
581+ }
582+
583+ async fn fail_job(&self, row: &JobRow, reason: &str) -> Result<()> {
584+ self.db
585+ .prepare("UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ? WHERE id = ? AND status != 'completed'")
586+ .bind(&[reason.into(), now().into(), row.id.as_str().into()])?
587+ .run()
588+ .await?;
589+ Ok(())
590+ }
591+
592+ /// Starts queued jobs, oldest first, while their workspace has room.
593+ pub async fn start_queued(&self) -> Result<()> {
594+ let queued = self
595+ .db
596+ .prepare("SELECT * FROM jobs WHERE status = 'queued' ORDER BY rowid LIMIT 50")
597+ .all()
598+ .await?
599+ .results::<JobRow>()?;
600+ let mut running: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
601+ for job in queued {
602+ let in_workspace = match running.get(&job.namespace) {
603+ Some(n) => *n,
604+ None => {
605+ let n = self
606+ .db
607+ .prepare("SELECT COUNT(*) AS n FROM jobs WHERE status = 'in_progress' AND namespace = ?")
608+ .bind(&[job.namespace.as_str().into()])?
609+ .first::<Count>(None)
610+ .await?
611+ .map_or(0, |count| count.n);
612+ running.insert(job.namespace.clone(), n);
613+ n
614+ }
615+ };
616+ if in_workspace >= RUNNING_PER_WORKSPACE {
617+ continue;
618+ }
619+ if let Some(max) = job.max_parallel {
620+ let siblings = self
621+ .db
622+ .prepare("SELECT COUNT(*) AS n FROM jobs WHERE run_id = ? AND key = ? AND status = 'in_progress'")
623+ .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])?
624+ .first::<Count>(None)
625+ .await?
626+ .map_or(0, |count| count.n);
627+ if siblings >= max {
628+ continue;
629+ }
630+ }
631+ let token = random_hex(24);
632+ let at = now();
633+ let claimed = self
634+ .db
635+ .prepare(
636+ "UPDATE jobs SET status = 'in_progress', token_hash = ?, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id",
637+ )
638+ .bind(&[sha256_hex(&token).into(), at.as_str().into(), at.as_str().into(), job.id.as_str().into()])?
639+ .first::<Value>(None)
640+ .await?;
641+ if claimed.is_none() {
642+ continue;
643+ }
644+ running.insert(job.namespace.clone(), in_workspace + 1);
645+ self.db
646+ .prepare("UPDATE runs SET status = 'in_progress', started_at = COALESCE(started_at, ?) WHERE id = ? AND status = 'queued'")
647+ .bind(&[at.as_str().into(), job.run_id.as_str().into()])?
648+ .run()
649+ .await?;
650+ let run = self.run_row(&job.run_id).await?;
651+ let repo: RepoPath = run.as_ref().map(|run| repo_path(&run.repo)).unwrap_or(RepoPath {
652+ namespace: job.namespace.clone(),
653+ name: String::new(),
654+ });
655+ let started: Outcome<Value> = g1t_kit::call(
656+ &self.runner,
657+ "start_actions_job",
658+ &StartJobArgs {
659+ job: job.id.clone(),
660+ token,
661+ repo,
662+ timeout_minutes: job.timeout_minutes,
663+ },
664+ )
665+ .await
666+ .unwrap_or_else(|error| fail(FailureCode::Conflict, format!("The runner could not be reached: {error}")));
667+ if let Outcome::Fail(refused) = started {
668+ Box::pin(self.finish_job(&job.id, "failure", Some(&refused.message), None)).await?;
669+ }
670+ }
671+ Ok(())
672+ }
673+
674+ /// Finishes a job and moves its run along.
675+ pub async fn finish_job(&self, job_id: &str, conclusion: &str, reason: Option<&str>, outputs: Option<&Map<String, Value>>) -> Result<()> {
676+ let finished = self
677+ .db
678+ .prepare(
679+ "UPDATE jobs SET status = 'completed', conclusion = ?, reason = COALESCE(?, reason), outputs = COALESCE(?, outputs),
680+ finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *",
681+ )
682+ .bind(&[
683+ conclusion.into(),
684+ optional(reason),
685+ outputs.map(|o| serde_json::to_string(o).unwrap_or_default()).as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
686+ now().into(),
687+ job_id.into(),
688+ ])?
689+ .first::<JobRow>(None)
690+ .await?;
691+ let Some(job) = finished else { return Ok(()) };
692+ // Steps still marked as going are not going any more.
693+ let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
694+ let mut touched = false;
695+ for step in steps.iter_mut() {
696+ if step["status"] != "completed" {
697+ let was_running = step["status"] == "in_progress";
698+ step["status"] = json!("completed");
699+ step["conclusion"] = json!(if was_running { conclusion } else { "skipped" });
700+ touched = true;
701+ }
702+ }
703+ if touched {
704+ self.db
705+ .prepare("UPDATE jobs SET steps = ? WHERE id = ?")
706+ .bind(&[serde_json::to_string(&steps)?.into(), job.id.as_str().into()])?
707+ .run()
708+ .await?;
709+ }
710+ // fail-fast: one failed combination stops the rest of its matrix.
711+ if conclusion == "failure" && job.continue_on_error == 0 && job.matrix.as_deref().is_some_and(|m| m != "{}") {
712+ let run = self.run_row(&job.run_id).await?;
713+ let fail_fast = run
714+ .as_ref()
715+ .and_then(|run| workflow::parse(&run.source).ok())
716+ .and_then(|workflow| workflow.jobs.into_iter().find(|j| j.id == job.key))
717+ .is_none_or(|j| j.fail_fast);
718+ if fail_fast {
719+ let siblings = self
720+ .db
721+ .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'")
722+ .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])?
723+ .all()
724+ .await?
725+ .results::<JobRow>()?;
726+ for sibling in siblings {
727+ self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?;
728+ }
729+ }
730+ }
731+ Box::pin(self.advance(&job.run_id)).await
732+ }
733+
734+ /// Cancels a job, stopping its sandbox if it has one.
735+ async fn stop_job(&self, job: &JobRow, reason: &str) -> Result<()> {
736+ if job.status == "in_progress" {
737+ let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await;
738+ }
739+ self.db
740+ .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed'")
741+ .bind(&[reason.into(), now().into(), job.id.as_str().into()])?
742+ .run()
743+ .await?;
744+ Ok(())
745+ }
746+
747+ /// Finishes the run when every job has.
748+ async fn finish_if_done(&self, run_id: &str) -> Result<()> {
749+ let Some(run) = self.run_row(run_id).await? else { return Ok(()) };
750+ if run.status == "completed" || run.status == "pending" {
751+ return Ok(());
752+ }
753+ let jobs = self.job_rows(run_id).await?;
754+ if !jobs.iter().all(|job| job.status == "completed") {
755+ return Ok(());
756+ }
757+ self.finish_run(&run, None).await
758+ }
759+
760+ async fn finish_run(&self, run: &RunRow, error: Option<&str>) -> Result<()> {
761+ let jobs = self.job_rows(&run.id).await?;
762+ let rows: Vec<&JobRow> = jobs.iter().collect();
763+ let conclusion = if error.is_some() {
764+ "failure"
765+ } else if run.conclusion.as_deref() == Some("cancelled") {
766+ "cancelled"
767+ } else if rows.is_empty() {
768+ "skipped"
769+ } else {
770+ key_result(&rows)
771+ };
772+ let done = self
773+ .db
774+ .prepare("UPDATE runs SET status = 'completed', conclusion = ?, error = COALESCE(?, error), finished_at = ? WHERE id = ? AND status != 'completed' RETURNING id")
775+ .bind(&[conclusion.into(), optional(error), now().into(), run.id.as_str().into()])?
776+ .first::<Value>(None)
777+ .await?;
778+ if done.is_none() {
779+ return Ok(());
780+ }
781+ self.report_status(run, conclusion).await?;
782+ // The next run waiting in its concurrency group.
783+ if let Some(group) = &run.concurrency_group {
784+ let next = self
785+ .db
786+ .prepare("SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND status = 'pending' ORDER BY id LIMIT 1")
787+ .bind(&[run.repo_id.as_str().into(), group.as_str().into()])?
788+ .first::<RunRow>(None)
789+ .await?;
790+ if let Some(next) = next {
791+ self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[next.id.as_str().into()])?.run().await?;
792+ Box::pin(self.advance(&next.id)).await?;
793+ }
794+ }
795+ Ok(())
796+ }
797+
798+ /// Tells the pull request (or commit) how the run went, as a status.
799+ async fn report_status(&self, run: &RunRow, conclusion: &str) -> Result<()> {
800+ let state = match conclusion {
801+ "success" | "skipped" => "success",
802+ "cancelled" => "error",
803+ _ => "failure",
804+ };
805+ let _: Result<Value> = g1t_kit::call(
806+ &self.work,
807+ "set_commit_status",
808+ &json!({
809+ "repoId": run.repo_id,
810+ "sha": run.sha,
811+ "context": format!("{} / {}", run.name, run.event),
812+ "state": state,
813+ "description": format!("{} {}", run.name, match conclusion {
814+ "success" => "passed",
815+ "skipped" => "was skipped",
816+ "cancelled" => "was cancelled",
817+ _ => "failed",
818+ }),
819+ "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
820+ }),
821+ )
822+ .await;
823+ Ok(())
824+ }
825+
826+ /// Tells the pull request a run has started on its head.
827+ pub async fn report_pending(&self, run: &RunRow) -> Result<()> {
828+ let _: Result<Value> = g1t_kit::call(
829+ &self.work,
830+ "set_commit_status",
831+ &json!({
832+ "repoId": run.repo_id,
833+ "sha": run.sha,
834+ "context": format!("{} / {}", run.name, run.event),
835+ "state": "pending",
836+ "description": format!("{} is running", run.name),
837+ "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id),
838+ }),
839+ )
840+ .await;
841+ Ok(())
842+ }
843+
844+ /// Cancels a run: its waiting and queued jobs, and stops its running ones.
845+ pub async fn cancel_run(&self, run: &RunRow, reason: &str) -> Result<()> {
846+ self.db
847+ .prepare("UPDATE runs SET conclusion = 'cancelled' WHERE id = ? AND status != 'completed'")
848+ .bind(&[run.id.as_str().into()])?
849+ .run()
850+ .await?;
851+ for job in self.job_rows(&run.id).await?.iter().filter(|job| job.status != "completed") {
852+ self.stop_job(job, reason).await?;
853+ }
854+ if run.status == "pending" {
855+ self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[run.id.as_str().into()])?.run().await?;
856+ }
857+ self.advance(&run.id).await
858+ }
859+
860+ pub async fn cancel(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> {
861+ if let Some(Outcome::Fail(refused)) = Self::member(&a.actor, &a.repo) {
862+ return Ok(Outcome::Fail(refused));
863+ }
864+ let run = check!(self.run_in(&a.repo, &a.id).await?);
865+ if run.status == "completed" {
866+ return Ok(fail(FailureCode::Conflict, "The run has already finished."));
867+ }
868+ self.cancel_run(&run, &format!("{} cancelled the run.", a.actor.username)).await?;
869+ self.run_summary(&run.id).await
870+ }
871+
872+ /// Runs again: every job, or with `failed_only` those that did not
873+ /// succeed and the jobs that need them.
874+ pub async fn rerun(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> {
875+ if let Some(Outcome::Fail(refused)) = Self::member(&a.actor, &a.repo) {
876+ return Ok(Outcome::Fail(refused));
877+ }
878+ let run = check!(self.run_in(&a.repo, &a.id).await?);
879+ if run.status != "completed" {
880+ return Ok(fail(FailureCode::Conflict, "The run is still going: cancel it first."));
881+ }
882+ if run.error.is_some() {
883+ return Ok(fail(FailureCode::Conflict, "This run never started: fix the workflow file and push again."));
884+ }
885+ let jobs = self.job_rows(&run.id).await?;
886+ let workflow = workflow::parse(&run.source).ok();
887+ // Which keys run again: failed ones and, transitively, those needing them.
888+ let mut again: Vec<String> = Vec::new();
889+ for key in workflow.as_ref().map(|w| w.job_order()).unwrap_or_default() {
890+ let rows: Vec<&JobRow> = jobs.iter().filter(|j| j.key == key).collect();
891+ let failed = rows.iter().any(|row| row.conclusion.as_deref() != Some("success"));
892+ let needs_again = rows.first().is_some_and(|row| row.needs().iter().any(|need| again.contains(need)));
893+ if !a.failed_only || failed || needs_again {
894+ again.push(key.to_owned());
895+ }
896+ }
897+ if again.is_empty() {
898+ return Ok(fail(FailureCode::Conflict, "Every job succeeded: there is nothing to run again."));
899+ }
900+ let mut statements = Vec::new();
901+ for key in &again {
902+ statements.push(self.db.prepare("DELETE FROM logs WHERE job_id IN (SELECT id FROM jobs WHERE run_id = ? AND key = ?)").bind(&[run.id.as_str().into(), key.as_str().into()])?);
903+ 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()])?);
904+ statements.push(
905+ self.db
906+ .prepare(
907+ "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL,
908+ matrix = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL WHERE run_id = ? AND key = ?",
909+ )
910+ .bind(&[run.id.as_str().into(), key.as_str().into()])?,
911+ );
912+ }
913+ statements.push(
914+ self.db
915+ .prepare("UPDATE runs SET status = 'queued', conclusion = NULL, attempt = attempt + 1, started_at = NULL, finished_at = NULL WHERE id = ?")
916+ .bind(&[run.id.as_str().into()])?,
917+ );
918+ self.db.batch(statements).await?;
919+ if let Some(run) = self.run_row(&run.id).await? {
920+ self.report_pending(&run).await?;
921+ }
922+ self.advance(&run.id).await?;
923+ self.run_summary(&run.id).await
924+ }
925+
926+ pub async fn run_in(&self, repo: &RepoPath, id: &str) -> Result<Outcome<RunRow>> {
927+ let row = self
928+ .db
929+ .prepare("SELECT * FROM runs WHERE id = ? AND lower(repo) = lower(?)")
930+ .bind(&[id.into(), format!("{}/{}", repo.namespace, repo.name).into()])?
931+ .first::<RunRow>(None)
932+ .await?;
933+ Ok(row.map_or_else(|| fail(FailureCode::NotFound, "No such run."), Outcome::Ok))
934+ }
935+
936+ // --- The sandbox's side -----------------------------------------------------
937+
938+ async fn job_for_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> {
939+ let job = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.job.as_str().into()])?.first::<JobRow>(None).await?;
940+ Ok(match job {
941+ Some(job) if job.status == "in_progress" && job.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(&a.token))) => {
942+ Outcome::Ok(job)
943+ }
944+ _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
945+ })
946+ }
947+
948+ /// `job_spec`: everything the sandbox needs to run the job.
949+ pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
950+ let job = check!(self.job_for_token(&a).await?);
951+ let Some(run) = self.run_row(&job.run_id).await? else {
952+ return Ok(fail(FailureCode::NotFound, "No such run."));
953+ };
954+ let Ok(workflow) = workflow::parse(&run.source) else {
955+ return Ok(fail(FailureCode::Invalid, "The workflow no longer reads."));
956+ };
957+ let Some(spec) = workflow.jobs.iter().find(|j| j.id == job.key) else {
958+ return Ok(fail(FailureCode::NotFound, "The job is not in the workflow."));
959+ };
960+ let repo = repo_path(&run.repo);
961+ let trusted = run.trusted != 0;
962+ // GITHUB_TOKEN: the workspace's, for as long as the job may run.
963+ let token = if trusted {
964+ match self.workspace_actor(&repo.namespace).await? {
965+ Some(workspace) => {
966+ let created: CreatedAccessToken = g1t_kit::call(
967+ &self.identity,
968+ "create_access_token",
969+ &CreateAccessTokenArgs {
970+ user: workspace,
971+ name: format!("GITHUB_TOKEN for {} run {}", run.repo, run.number),
972+ ttl_seconds: Some(u64::from(job.timeout_minutes) * 60 + 600),
973+ },
974+ )
975+ .await?;
976+ created.token
977+ }
978+ None => String::new(),
979+ }
980+ } else {
981+ String::new()
982+ };
983+ let mut secrets = if trusted { self.secrets_for(&run.repo_id, &repo.namespace).await? } else { Map::new() };
984+ secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone()));
985+ let masks: Vec<String> = secrets.values().filter_map(|v| v.as_str()).filter(|v| v.len() >= 4).map(str::to_owned).collect();
986+ let vars = self.variables_for(&run.repo_id, &repo.namespace).await?;
987+
988+ let jobs = self.job_rows(&run.id).await?;
989+ let mut needs = Map::new();
990+ for need in &spec.needs {
991+ let rows: Vec<&JobRow> = jobs.iter().filter(|row| &row.key == need).collect();
992+ let mut outputs = Map::new();
993+ for row in &rows {
994+ if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) {
995+ outputs.extend(more);
996+ }
997+ }
998+ needs.insert(need.clone(), json!({ "result": key_result(&rows), "outputs": outputs }));
999+ }
1000+ let siblings = jobs.iter().filter(|row| row.key == job.key).count();
1001+ let matrix: Value = job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or(json!({}));
1002+ let info = run.info();
1003+ let mut github = info.context(&job.key, &token, run.action.as_deref());
1004+ github["token"] = json!(token);
1005+
1006+ // Where to check out: a pull request's fork, or the repository.
1007+ let clone_url = match run.pull {
1008+ Some(number) if run.event.starts_with("pull_request") && run.event != "pull_request_target" => {
1009+ let located: Outcome<g1t_contracts::work::PullDetail> = g1t_kit::call(
1010+ &self.work,
1011+ "get_pull",
1012+ &g1t_contracts::work::ViewArgs {
1013+ repo: repo.clone(),
1014+ number,
1015+ viewer: self.workspace_actor(&repo.namespace).await?,
1016+ after_seq: 0,
1017+ },
1018+ )
1019+ .await?;
1020+ match located {
1021+ Outcome::Ok(detail) => match detail.pull.fork {
1022+ Some(fork) => format!("{SITE}/{}/{}.git", fork.namespace, fork.name),
1023+ None => format!("{SITE}/{}.git", run.repo),
1024+ },
1025+ Outcome::Fail(_) => format!("{SITE}/{}.git", run.repo),
1026+ }
1027+ }
1028+ _ => format!("{SITE}/{}.git", run.repo),
1029+ };
1030+
1031+ Ok(Outcome::Ok(json!({
1032+ "job": job.id,
1033+ "run": run.id,
1034+ "key": job.key,
1035+ "name": job.name,
1036+ "spec": spec.raw,
1037+ "workflow": {
1038+ "env": workflow.env,
1039+ "defaults": workflow.raw.get("defaults").cloned().unwrap_or(Value::Null),
1040+ },
1041+ "github": github,
1042+ "variables": info.variables(&job.key),
1043+ "event": info.event,
1044+ "contexts": {
1045+ "vars": vars,
1046+ "secrets": secrets,
1047+ "inputs": run.inputs(),
1048+ "matrix": matrix,
1049+ "needs": needs,
1050+ "strategy": {
1051+ "fail-fast": spec.fail_fast,
1052+ "job-index": job.ordinal,
1053+ "job-total": siblings,
1054+ "max-parallel": spec.max_parallel.unwrap_or(siblings as u32),
1055+ },
1056+ "runner": runner_context(),
1057+ },
1058+ "checkout": {
1059+ "repository": run.repo,
1060+ "url": clone_url,
1061+ "sha": run.sha,
1062+ "ref": run.git_ref,
1063+ "token": token,
1064+ },
1065+ "timeoutMinutes": job.timeout_minutes,
1066+ "masks": masks,
1067+ })))
1068+ }
1069+
1070+ /// `job_report`: the sandbox telling how the job is going.
1071+ pub async fn job_report(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
1072+ let job = check!(self.job_for_token(&a).await?);
1073+ let report = &a.report;
1074+ let at = now();
1075+ match report["kind"].as_str().unwrap_or_default() {
1076+ "steps" => {
1077+ // The list can grow as the job goes (post steps), so steps
1078+ // already reported keep where they stand.
1079+ let known: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
1080+ let steps: Vec<Value> = report["steps"]
1081+ .as_array()
1082+ .map(|names| {
1083+ names
1084+ .iter()
1085+ .enumerate()
1086+ .map(|(i, name)| match known.get(i) {
1087+ Some(step) if step["status"] != "queued" => step.clone(),
1088+ _ => json!({ "number": i + 1, "name": expr::to_text(name), "status": "queued", "conclusion": null, "startedAt": null, "finishedAt": null }),
1089+ })
1090+ .collect()
1091+ })
1092+ .unwrap_or_default();
1093+ self.db
1094+ .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?")
1095+ .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])?
1096+ .run()
1097+ .await?;
1098+ }
1099+ "step" => {
1100+ let number = report["number"].as_u64().unwrap_or(0) as usize;
1101+ let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
1102+ if let Some(step) = number.checked_sub(1).and_then(|i| steps.get_mut(i)) {
1103+ let status = report["status"].as_str().unwrap_or("in_progress");
1104+ step["status"] = json!(status);
1105+ if status == "in_progress" {
1106+ step["startedAt"] = json!(at);
1107+ }
1108+ if status == "completed" {
1109+ step["finishedAt"] = json!(at);
1110+ step["conclusion"] = report["conclusion"].clone();
1111+ }
1112+ if let Some(name) = report["name"].as_str() {
1113+ step["name"] = json!(name);
1114+ }
1115+ }
1116+ self.db
1117+ .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?")
1118+ .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])?
1119+ .run()
1120+ .await?;
1121+ }
1122+ "log" => {
1123+ let mut text = report["text"].as_str().unwrap_or_default().to_owned();
1124+ if text.len() > MAX_CHUNK_BYTES {
1125+ let mut cut = MAX_CHUNK_BYTES;
1126+ while !text.is_char_boundary(cut) {
1127+ cut -= 1;
1128+ }
1129+ text.truncate(cut);
1130+ }
1131+ #[derive(Deserialize)]
1132+ struct Size {
1133+ n: Option<u64>,
1134+ seq: Option<u64>,
1135+ }
1136+ let size = self
1137+ .db
1138+ .prepare("SELECT SUM(LENGTH(text)) AS n, MAX(seq) AS seq FROM logs WHERE job_id = ?")
1139+ .bind(&[job.id.as_str().into()])?
1140+ .first::<Size>(None)
1141+ .await?;
1142+ let (used, seq) = size.map_or((0, 0), |s| (s.n.unwrap_or(0) as usize, s.seq.unwrap_or(0)));
1143+ if used < MAX_LOG_BYTES {
1144+ if used + text.len() >= MAX_LOG_BYTES {
1145+ text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n");
1146+ }
1147+ self.db
1148+ .prepare("INSERT INTO logs (job_id, seq, step, text) VALUES (?, ?, ?, ?)")
1149+ .bind(&[job.id.as_str().into(), (seq + 1).into(), (report["step"].as_u64().unwrap_or(0) as u32).into(), text.into()])?
1150+ .run()
1151+ .await?;
1152+ }
1153+ self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
1154+ }
1155+ "annotation" => {
1156+ let mut annotations: Vec<Value> = serde_json::from_str(&job.annotations).unwrap_or_default();
1157+ if annotations.len() < MAX_ANNOTATIONS {
1158+ annotations.push(json!({
1159+ "level": report["level"].as_str().unwrap_or("notice"),
1160+ "message": report["message"].as_str().unwrap_or_default().chars().take(4000).collect::<String>(),
1161+ "title": report["title"],
1162+ "file": report["file"],
1163+ "line": report["line"],
1164+ }));
1165+ self.db
1166+ .prepare("UPDATE jobs SET annotations = ?, seen_at = ? WHERE id = ?")
1167+ .bind(&[serde_json::to_string(&annotations)?.into(), at.as_str().into(), job.id.as_str().into()])?
1168+ .run()
1169+ .await?;
1170+ }
1171+ }
1172+ "done" => {
1173+ let conclusion = report["conclusion"]
1174+ .as_str()
1175+ .filter(|c| matches!(*c, "success" | "failure" | "cancelled"))
1176+ .unwrap_or("failure");
1177+ let outputs = report["outputs"].as_object().cloned();
1178+ Box::pin(self.finish_job(&job.id, conclusion, report["reason"].as_str(), outputs.as_ref())).await?;
1179+ }
1180+ other => return Ok(fail(FailureCode::Invalid, format!("There is no report called `{other}`."))),
1181+ }
1182+ Ok(Outcome::Ok(json!({ "ok": true })))
1183+ }
1184+
1185+ // --- Every minute ---------------------------------------------------------------
1186+
1187+ pub async fn on_minute(&self, now_ms: u64) -> Result<()> {
1188+ let minute = now_ms / 60_000 * 60_000;
1189+ if let Err(error) = self.run_schedules(minute).await {
1190+ worker::console_error!("actions: schedules failed: {error}");
1191+ }
1192+ // Jobs whose sandbox went quiet or ran past their time.
1193+ let running = self.db.prepare("SELECT * FROM jobs WHERE status = 'in_progress'").all().await?.results::<JobRow>()?;
1194+ for job in running {
1195+ // Times in g1t's format compare as text.
1196+ let before = |ms: u64| rfc3339(now_ms.saturating_sub(ms));
1197+ let silent = job.seen_at.as_deref().is_some_and(|seen| seen < before(SILENT_MS).as_str());
1198+ let limit = (u64::from(job.timeout_minutes) * 60 + 120) * 1000;
1199+ let over = job.started_at.as_deref().is_some_and(|started| started < before(limit).as_str());
1200+ if over {
1201+ let reason = format!("It ran longer than its time limit of {} minutes.", job.timeout_minutes);
1202+ let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await;
1203+ self.finish_job(&job.id, "failure", Some(&reason), None).await?;
1204+ } else if silent {
1205+ self.finish_job(&job.id, "failure", Some("The runner stopped answering."), None).await?;
1206+ }
1207+ }
1208+ self.start_queued().await
1209+ }
1210+}
1211+
+239−0
1+//! Secrets and variables, a repository's or its workspace's. A
2+//! repository's override its workspace's of the same name. Names are
3+//! upper-cased, as GitHub treats them without regard to case.
4+
5+use g1t_contracts::actions::{DeleteSettingArgs, SetSettingArgs, Setting, SettingsArgs, SettingsOwner};
6+use g1t_contracts::time::rfc3339;
7+use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
8+use g1t_kit::now_ms;
9+use serde::Deserialize;
10+use serde_json::{Map, Value};
11+use worker::Result;
12+
13+use crate::{Actions, check, fail};
14+
15+/// The largest value, as on GitHub.
16+const MAX_VALUE_BYTES: usize = 48 * 1024;
17+const MAX_PER_OWNER: u32 = 100;
18+
19+#[derive(Deserialize)]
20+struct SettingRow {
21+ id: String,
22+ scope: String,
23+ name: String,
24+ value: String,
25+ updated_at: String,
26+}
27+
28+#[derive(Deserialize)]
29+struct Count {
30+ n: u32,
31+}
32+
33+/// A name GitHub would accept: letters, digits and `_`, not starting with
34+/// a digit or `GITHUB_`.
35+fn valid_name(name: &str) -> Result<String, String> {
36+ let upper = name.trim().to_ascii_uppercase();
37+ if upper.is_empty() || upper.len() > 100 {
38+ return Err("A name is 1 to 100 characters.".to_owned());
39+ }
40+ if !upper.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
41+ return Err("A name has only letters, digits and underscores.".to_owned());
42+ }
43+ if upper.starts_with(|c: char| c.is_ascii_digit()) {
44+ return Err("A name cannot start with a digit.".to_owned());
45+ }
46+ if upper.starts_with("GITHUB_") {
47+ return Err("Names starting with GITHUB_ are kept for GitHub's own.".to_owned());
48+ }
49+ Ok(upper)
50+}
51+
52+/// Where settings live: `(scope, owner)` with the owner a repository id or
53+/// a workspace slug, and whether the actor may change them.
54+struct Place {
55+ scope: &'static str,
56+ owner: String,
57+ namespace: String,
58+}
59+
60+impl Actions {
61+ async fn place(&self, actor: &User, owner: &SettingsOwner, changing: bool) -> Result<Outcome<Place>> {
62+ if actor.kind == PrincipalKind::Agent {
63+ return Ok(fail(FailureCode::Forbidden, "Agents cannot read or change secrets and variables."));
64+ }
65+ match (&owner.repo, &owner.workspace) {
66+ (Some(path), _) => {
67+ if !actor.is_member(&path.namespace.to_lowercase()) {
68+ return Ok(fail(FailureCode::Forbidden, format!("Only members of {} can see its secrets and variables.", path.namespace)));
69+ }
70+ let Some(repo) = self.visible_repo(path, &Some(actor.clone())).await? else {
71+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
72+ };
73+ Ok(Outcome::Ok(Place { scope: "repository", owner: repo.id, namespace: repo.namespace }))
74+ }
75+ (None, Some(slug)) => {
76+ let slug = slug.to_lowercase();
77+ let role = actor.workspaces.iter().find(|m| m.slug.eq_ignore_ascii_case(&slug)).map(|m| m.role);
78+ match role {
79+ None => Ok(fail(FailureCode::Forbidden, format!("Only members of {slug} can see its secrets and variables."))),
80+ Some(Role::Member) if changing => Ok(fail(FailureCode::Forbidden, format!("Only owners of {slug} can change its secrets and variables."))),
81+ Some(_) => Ok(Outcome::Ok(Place { scope: "workspace", owner: slug.clone(), namespace: slug })),
82+ }
83+ }
84+ (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")),
85+ }
86+ }
87+
88+ fn kind(kind: &str) -> Outcome<&'static str> {
89+ match kind {
90+ "secret" | "secrets" => Outcome::Ok("secret"),
91+ "variable" | "variables" => Outcome::Ok("variable"),
92+ _ => fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."),
93+ }
94+ }
95+
96+ pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> {
97+ let kind = check!(Self::kind(&a.kind));
98+ let place = check!(self.place(&a.actor, &a.owner, false).await?);
99+ // A repository's list shows its workspace's too, which it inherits.
100+ let owners: Vec<&str> = if place.scope == "repository" { vec![place.namespace.as_str(), place.owner.as_str()] } else { vec![place.owner.as_str()] };
101+ let mut out: Vec<Setting> = Vec::new();
102+ for owner in owners {
103+ let rows = self
104+ .db
105+ .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ? ORDER BY name")
106+ .bind(&[owner.into(), kind.into()])?
107+ .all()
108+ .await?
109+ .results::<SettingRow>()?;
110+ for row in rows {
111+ out.retain(|setting| setting.name != row.name);
112+ out.push(Setting {
113+ name: row.name,
114+ value: (kind == "variable").then_some(row.value),
115+ scope: row.scope,
116+ updated_at: row.updated_at,
117+ });
118+ }
119+ }
120+ out.sort_by(|a, b| a.name.cmp(&b.name));
121+ Ok(Outcome::Ok(out))
122+ }
123+
124+ pub async fn set_setting(&self, a: SetSettingArgs) -> Result<Outcome<Setting>> {
125+ let kind = check!(Self::kind(&a.kind));
126+ let name = match valid_name(&a.name) {
127+ Ok(name) => name,
128+ Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
129+ };
130+ if a.value.len() > MAX_VALUE_BYTES {
131+ return Ok(fail(FailureCode::Invalid, "A value is at most 48 KB."));
132+ }
133+ let place = check!(self.place(&a.actor, &a.owner, true).await?);
134+ let count = self
135+ .db
136+ .prepare("SELECT COUNT(*) AS n FROM settings WHERE owner = ? AND kind = ? AND name != ?")
137+ .bind(&[place.owner.as_str().into(), kind.into(), name.as_str().into()])?
138+ .first::<Count>(None)
139+ .await?
140+ .map_or(0, |c| c.n);
141+ if count >= MAX_PER_OWNER {
142+ return Ok(fail(FailureCode::Invalid, format!("There can be at most {MAX_PER_OWNER} {kind}s here.")));
143+ }
144+ let existing = self
145+ .db
146+ .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ? AND name = ?")
147+ .bind(&[place.owner.as_str().into(), kind.into(), name.as_str().into()])?
148+ .first::<SettingRow>(None)
149+ .await?;
150+ let id = existing.map(|row| row.id).unwrap_or_else(|| new_id("set", now_ms()));
151+ let value = if kind == "secret" {
152+ let Some(sealer) = &self.sealer else {
153+ return Ok(fail(FailureCode::Conflict, "Secrets cannot be saved yet: g1t's key for them is not set."));
154+ };
155+ sealer.seal(&a.value, &id)
156+ } else {
157+ a.value.clone()
158+ };
159+ let at = rfc3339(now_ms());
160+ self.db
161+ .prepare(
162+ "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
163+ ON CONFLICT (owner, kind, name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
164+ )
165+ .bind(&[id.as_str().into(), place.scope.into(), place.owner.as_str().into(), kind.into(), name.as_str().into(), value.into(), at.as_str().into()])?
166+ .run()
167+ .await?;
168+ Ok(Outcome::Ok(Setting {
169+ name,
170+ value: (kind == "variable").then_some(a.value),
171+ scope: place.scope.to_owned(),
172+ updated_at: at,
173+ }))
174+ }
175+
176+ pub async fn delete_setting(&self, a: DeleteSettingArgs) -> Result<Outcome<bool>> {
177+ let kind = check!(Self::kind(&a.kind));
178+ let place = check!(self.place(&a.actor, &a.owner, true).await?);
179+ let removed = self
180+ .db
181+ .prepare("DELETE FROM settings WHERE owner = ? AND kind = ? AND name = ? RETURNING id")
182+ .bind(&[place.owner.as_str().into(), kind.into(), a.name.trim().to_ascii_uppercase().into()])?
183+ .first::<Value>(None)
184+ .await?;
185+ Ok(match removed {
186+ Some(_) => Outcome::Ok(true),
187+ None => fail(FailureCode::NotFound, format!("There is no {kind} called {}.", a.name)),
188+ })
189+ }
190+
191+ async fn resolved(&self, repo_id: &str, namespace: &str, kind: &str) -> Result<Map<String, Value>> {
192+ let mut out = Map::new();
193+ for owner in [namespace.to_lowercase(), repo_id.to_owned()] {
194+ let rows = self
195+ .db
196+ .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ?")
197+ .bind(&[owner.into(), kind.into()])?
198+ .all()
199+ .await?
200+ .results::<SettingRow>()?;
201+ for row in rows {
202+ let value = if kind == "secret" {
203+ match self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)) {
204+ Some(value) => value,
205+ None => continue,
206+ }
207+ } else {
208+ row.value
209+ };
210+ out.insert(row.name, Value::String(value));
211+ }
212+ }
213+ Ok(out)
214+ }
215+
216+ /// The `vars` context of a repository's runs.
217+ pub async fn variables_for(&self, repo_id: &str, namespace: &str) -> Result<Map<String, Value>> {
218+ self.resolved(repo_id, namespace, "variable").await
219+ }
220+
221+ /// The `secrets` context of a repository's runs, opened.
222+ pub async fn secrets_for(&self, repo_id: &str, namespace: &str) -> Result<Map<String, Value>> {
223+ self.resolved(repo_id, namespace, "secret").await
224+ }
225+}
226+
227+#[cfg(test)]
228+mod tests {
229+ use super::valid_name;
230+
231+ #[test]
232+ fn names_follow_githubs_rules() {
233+ assert_eq!(valid_name("npm_token").unwrap(), "NPM_TOKEN");
234+ assert!(valid_name("GITHUB_TOKEN").is_err());
235+ assert!(valid_name("1PASSWORD").is_err());
236+ assert!(valid_name("MY-TOKEN").is_err());
237+ assert!(valid_name("").is_err());
238+ }
239+}
+200−0
1+//! Reading workflow files: at any commit for a run, and from the default
2+//! branch into the `workflows` table, which lists them, holds their
3+//! schedules and remembers which are turned off.
4+
5+use g1t_actions::workflow::{self, FOLDER};
6+use g1t_contracts::new_id;
7+use g1t_contracts::repos::{BlobArgs, BlobView, EntryKind, Repo, RepoPath, TreeArgs, TreeView};
8+use g1t_contracts::time::rfc3339;
9+use g1t_contracts::{Outcome, User};
10+use g1t_kit::now_ms;
11+use serde::Deserialize;
12+use worker::Result;
13+
14+use crate::{Actions, Count, MAX_WORKFLOWS, optional};
15+
16+/// One workflow file as read at a commit.
17+pub struct WorkflowFile {
18+ pub path: String,
19+ pub source: String,
20+}
21+
22+/// The files at a commit, and the commit the ref resolved to.
23+pub struct Read {
24+ pub files: Vec<WorkflowFile>,
25+ pub head: Option<String>,
26+}
27+
28+#[derive(Deserialize)]
29+pub struct WorkflowRow {
30+ pub id: String,
31+ pub repo_id: String,
32+ pub repo: String,
33+ pub path: String,
34+ pub name: String,
35+ pub source: String,
36+ pub events: String,
37+ pub crons: String,
38+ pub error: Option<String>,
39+ pub state: String,
40+ pub updated_at: String,
41+}
42+
43+impl Actions {
44+ /// The workflow files of `path` as of `git_ref` (the default branch
45+ /// when absent).
46+ pub async fn read_workflows(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>) -> Result<Read> {
47+ let viewer = Some(actor.clone());
48+ let tree: Outcome<TreeView> = g1t_kit::call(
49+ &self.repos,
50+ "tree",
51+ &TreeArgs {
52+ path: path.clone(),
53+ viewer: viewer.clone(),
54+ git_ref: git_ref.map(str::to_owned),
55+ tree_path: FOLDER.to_owned(),
56+ },
57+ )
58+ .await?;
59+ let (entries, head, resolved) = match tree {
60+ Outcome::Ok(tree) => (tree.entries, tree.head.map(|commit| commit.hash), tree.git_ref),
61+ // No folder: no workflows.
62+ Outcome::Fail(_) => return Ok(Read { files: Vec::new(), head: None }),
63+ };
64+ let at = head.clone().unwrap_or(resolved);
65+ let mut files = Vec::new();
66+ for entry in entries
67+ .into_iter()
68+ .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::Exec))
69+ .filter(|entry| entry.name.ends_with(".yml") || entry.name.ends_with(".yaml"))
70+ .take(MAX_WORKFLOWS)
71+ {
72+ let file_path = format!("{FOLDER}/{}", entry.name);
73+ let blob: Outcome<BlobView> = g1t_kit::call(
74+ &self.repos,
75+ "blob",
76+ &BlobArgs {
77+ path: path.clone(),
78+ viewer: viewer.clone(),
79+ git_ref: at.clone(),
80+ file_path: file_path.clone(),
81+ },
82+ )
83+ .await?;
84+ if let Outcome::Ok(BlobView { text: Some(source), .. }) = blob {
85+ files.push(WorkflowFile { path: file_path, source });
86+ }
87+ }
88+ Ok(Read { files, head })
89+ }
90+
91+ /// Keeps the `workflows` table in step with the default branch.
92+ pub async fn sync(&self, repo: &Repo, actor: &User) -> Result<()> {
93+ let path = RepoPath {
94+ namespace: repo.namespace.clone(),
95+ name: repo.name.clone(),
96+ };
97+ let read = self.read_workflows(&path, actor, None).await?;
98+ let full_name = format!("{}/{}", repo.namespace, repo.name);
99+ let now = rfc3339(now_ms());
100+ let mut statements = Vec::new();
101+ for file in &read.files {
102+ let parsed = workflow::parse(&file.source);
103+ let (name, error, events, crons) = match &parsed {
104+ Ok(parsed) => (
105+ parsed.display_name(&file.path),
106+ None,
107+ parsed.triggers.iter().map(|t| t.event.clone()).collect::<Vec<_>>(),
108+ parsed.trigger("schedule").map(|t| t.crons.clone()).unwrap_or_default(),
109+ ),
110+ Err(problem) => (file.path.clone(), Some(problem.clone()), Vec::new(), Vec::new()),
111+ };
112+ statements.push(
113+ self.db
114+ .prepare(
115+ "INSERT INTO workflows (id, repo_id, repo, path, name, source, events, crons, error, updated_at)
116+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
117+ ON CONFLICT (repo_id, path) DO UPDATE SET
118+ repo = excluded.repo, name = excluded.name, source = excluded.source,
119+ events = excluded.events, crons = excluded.crons, error = excluded.error,
120+ updated_at = excluded.updated_at",
121+ )
122+ .bind(&[
123+ new_id("wfl", now_ms()).into(),
124+ repo.id.as_str().into(),
125+ full_name.as_str().into(),
126+ file.path.as_str().into(),
127+ name.into(),
128+ file.source.as_str().into(),
129+ serde_json::to_string(&events)?.into(),
130+ serde_json::to_string(&crons)?.into(),
131+ optional(error.as_deref()),
132+ now.as_str().into(),
133+ ])?,
134+ );
135+ }
136+ // A workflow whose file is gone keeps its runs, but no longer runs
137+ // on schedule or by hand: it is listed only while it has runs.
138+ let kept: Vec<String> = read.files.iter().map(|file| file.path.clone()).collect();
139+ let existing = self
140+ .db
141+ .prepare("SELECT * FROM workflows WHERE repo_id = ?")
142+ .bind(&[repo.id.as_str().into()])?
143+ .all()
144+ .await?
145+ .results::<WorkflowRow>()?;
146+ for row in existing.iter().filter(|row| !kept.contains(&row.path)) {
147+ statements.push(
148+ self.db
149+ .prepare("UPDATE workflows SET crons = '[]', error = 'Its file is no longer on the default branch.' WHERE id = ?")
150+ .bind(&[row.id.as_str().into()])?,
151+ );
152+ }
153+ statements.push(
154+ self.db
155+ .prepare("INSERT OR REPLACE INTO synced (repo_id, at) VALUES (?, ?)")
156+ .bind(&[repo.id.as_str().into(), now.into()])?,
157+ );
158+ self.db.batch(statements).await?;
159+ Ok(())
160+ }
161+
162+ pub async fn synced(&self, repo_id: &str) -> Result<bool> {
163+ Ok(self
164+ .db
165+ .prepare("SELECT COUNT(*) AS n FROM synced WHERE repo_id = ?")
166+ .bind(&[repo_id.into()])?
167+ .first::<Count>(None)
168+ .await?
169+ .is_some_and(|count| count.n > 0))
170+ }
171+
172+ /// The row for a workflow file, made if it is new (a file that exists
173+ /// only on a branch still gets its runs counted and listed).
174+ pub async fn workflow_row(&self, repo: &Repo, path: &str, name: &str, source: &str) -> Result<WorkflowRow> {
175+ let now = rfc3339(now_ms());
176+ self.db
177+ .prepare(
178+ "INSERT INTO workflows (id, repo_id, repo, path, name, source, events, error, updated_at)
179+ VALUES (?, ?, ?, ?, ?, ?, '[]', 'Its file is not on the default branch.', ?)
180+ ON CONFLICT (repo_id, path) DO NOTHING",
181+ )
182+ .bind(&[
183+ new_id("wfl", now_ms()).into(),
184+ repo.id.as_str().into(),
185+ format!("{}/{}", repo.namespace, repo.name).into(),
186+ path.into(),
187+ name.into(),
188+ source.into(),
189+ now.into(),
190+ ])?
191+ .run()
192+ .await?;
193+ self.db
194+ .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
195+ .bind(&[repo.id.as_str().into(), path.into()])?
196+ .first::<WorkflowRow>(None)
197+ .await?
198+ .ok_or_else(|| worker::Error::RustError("the workflow was not recorded".into()))
199+ }
200+}
+626−0
1+//! What starts a run: an event on the bus, a schedule, or someone running a
2+//! workflow by hand. Each finds the workflows that want it, at the commit
3+//! the event is about, and checks their filters.
4+
5+use g1t_actions::events::{RunInfo, github_events};
6+use g1t_actions::workflow::{self, Trigger, Workflow};
7+use g1t_contracts::actions::{DispatchArgs, WorkflowRun};
8+use g1t_contracts::events::Event;
9+use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernameArgs, UsernamesArgs};
10+use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, Repo, RepoPath};
11+use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs};
12+use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
13+use g1t_kit::now_ms;
14+use serde_json::{Map, Value, json};
15+use worker::Result;
16+
17+use crate::plan::NewRun;
18+use crate::sync::{Read, WorkflowRow};
19+use crate::{API, Actions, SITE, check, fail, payload};
20+
21+/// What an event is about, worked out once for every workflow it starts.
22+struct Subject {
23+ /// Where the workflow files are read, and at which commit.
24+ source: RepoPath,
25+ source_ref: Option<String>,
26+ git_ref: String,
27+ sha: String,
28+ head_ref: Option<String>,
29+ base_ref: Option<String>,
30+ pull: Option<u32>,
31+ /// The branch or tag for `branches`/`tags` filters; for pull requests,
32+ /// the branch they merge into.
33+ filter_ref: String,
34+ /// The files it changes, for `paths` filters; `None` until needed.
35+ paths: Option<Vec<String>>,
36+ /// For a push, what to compare to find the files.
37+ compare: Option<(Option<String>, String)>,
38+ payload: Value,
39+ title: String,
40+ trusted: bool,
41+}
42+
43+impl Actions {
44+ async fn username(&self, id: Option<&str>) -> Result<Option<String>> {
45+ let Some(id) = id else { return Ok(None) };
46+ if id == AGENT_ID {
47+ return Ok(Some(AGENT_NAME.to_owned()));
48+ }
49+ let names: std::collections::HashMap<String, String> =
50+ g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.to_owned()] }).await?;
51+ Ok(names.get(id).cloned())
52+ }
53+
54+ /// Whether someone belongs to the workspace, so their pull requests'
55+ /// runs get the secrets.
56+ async fn insider(&self, author: &User, namespace: &str) -> Result<bool> {
57+ if author.id == AGENT_ID || author.is_member(&namespace.to_lowercase()) {
58+ return Ok(true);
59+ }
60+ let found: Viewer = g1t_kit::call(&self.identity, "user_by_username", &UsernameArgs { username: author.username.clone() }).await?;
61+ Ok(found.is_some_and(|user| user.is_member(&namespace.to_lowercase())))
62+ }
63+
64+ async fn commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<Vec<Commit>> {
65+ let log: Outcome<Vec<Commit>> = g1t_kit::call(
66+ &self.repos,
67+ "log",
68+ &LogArgs {
69+ path: RepoPath {
70+ namespace: repo.namespace.clone(),
71+ name: repo.name.clone(),
72+ },
73+ viewer: Some(actor.clone()),
74+ git_ref: Some(after.to_owned()),
75+ limit: 20,
76+ },
77+ )
78+ .await?;
79+ let mut commits: Vec<Commit> = log.into_result().unwrap_or_default();
80+ if let Some(before) = before
81+ && let Some(at) = commits.iter().position(|commit| commit.hash == before)
82+ {
83+ commits.truncate(at);
84+ }
85+ // GitHub lists them oldest first, with the head commit last.
86+ commits.reverse();
87+ Ok(commits)
88+ }
89+
90+ async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> {
91+ let compared: Outcome<Comparison> = g1t_kit::call(
92+ &self.repos,
93+ "compare",
94+ &CompareArgs {
95+ repo_id: repo.id.clone(),
96+ viewer: Some(actor.clone()),
97+ base,
98+ head: Some(head),
99+ },
100+ )
101+ .await?;
102+ Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| f.path).collect()).unwrap_or_default())
103+ }
104+
105+ async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
106+ g1t_kit::call(
107+ &self.repos,
108+ "head",
109+ &g1t_contracts::repos::HeadArgs {
110+ repo_id: repo.id.clone(),
111+ branch: repo.default_branch.clone(),
112+ },
113+ )
114+ .await
115+ }
116+
117+ fn repo_path(repo: &Repo) -> RepoPath {
118+ RepoPath {
119+ namespace: repo.namespace.clone(),
120+ name: repo.name.clone(),
121+ }
122+ }
123+
124+ /// The subject of an event of `kind`, as GitHub's `event_name`.
125+ async fn subject(&self, event: &Event, event_name: &str, action: Option<&str>, repo: &Repo, ws: &User, sender: &str) -> Result<Option<Subject>> {
126+ let path = Self::repo_path(repo);
127+ let data = &event.data;
128+ let on_default = |sha: String, payload: Value, title: String, pull: Option<u32>| Subject {
129+ source: path.clone(),
130+ source_ref: None,
131+ git_ref: format!("refs/heads/{}", repo.default_branch),
132+ sha,
133+ head_ref: None,
134+ base_ref: None,
135+ pull,
136+ filter_ref: format!("refs/heads/{}", repo.default_branch),
137+ paths: None,
138+ compare: None,
139+ payload,
140+ title,
141+ trusted: true,
142+ };
143+ let view = |number: u32| ViewArgs {
144+ repo: path.clone(),
145+ number,
146+ viewer: Some(ws.clone()),
147+ after_seq: 0,
148+ };
149+ Ok(match event_name {
150+ "push" => {
151+ let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
152+ return Ok(None);
153+ };
154+ let before = data["before"].as_str();
155+ let commits = self.commits(repo, ws, after, before).await?;
156+ let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
157+ let mut payload = payload::push(repo, git_ref, before, after, &commits, sender);
158+ if let Some(head) = commits.last() {
159+ payload["head_commit"] = payload::commit(repo, head);
160+ }
161+ Some(Subject {
162+ source: path.clone(),
163+ source_ref: Some(after.to_owned()),
164+ git_ref: git_ref.to_owned(),
165+ sha: after.to_owned(),
166+ head_ref: None,
167+ base_ref: None,
168+ pull: None,
169+ filter_ref: git_ref.to_owned(),
170+ paths: None,
171+ compare: Some((before.map(str::to_owned), after.to_owned())),
172+ payload,
173+ title,
174+ trusted: true,
175+ })
176+ }
177+ "pull_request" | "pull_request_target" | "pull_request_review" => {
178+ let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
179+ let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
180+ let Outcome::Ok(detail) = detail else { return Ok(None) };
181+ let pull = &detail.pull;
182+ let labels = detail.issue.as_ref().map(|i| i.labels.clone()).unwrap_or_default();
183+ let mut payload = json!({
184+ "action": action,
185+ "number": pull.number,
186+ "pull_request": payload::pull(repo, pull, &labels),
187+ "repository": payload::repository(repo),
188+ "sender": payload::user(sender),
189+ });
190+ if event_name == "pull_request_review" {
191+ let review = detail.comments.iter().rev().find(|c| c.verdict.is_some());
192+ payload["review"] = json!({
193+ "state": review.and_then(|r| r.verdict).map(|v| format!("{v:?}").to_lowercase()),
194+ "body": review.map(|r| r.body.clone()),
195+ "user": review.map(|r| payload::user(&r.author.username)),
196+ });
197+ }
198+ let trusted = self.insider(&pull.author, &repo.namespace).await?;
199+ let head_ref = payload::head_ref(pull);
200+ if event_name == "pull_request_target" {
201+ // In the base's context: its workflows, its head.
202+ let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
203+ let mut subject = on_default(sha, payload, pull.title.clone(), Some(pull.number));
204+ subject.head_ref = Some(head_ref);
205+ subject.base_ref = Some(repo.default_branch.clone());
206+ subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect());
207+ return Ok(Some(subject));
208+ }
209+ // A merged pull request's run is on the commit it landed as.
210+ let sha = match (action, data["commit"].as_str()) {
211+ (Some("closed"), Some(commit)) => commit.to_owned(),
212+ _ => match &pull.head_commit {
213+ Some(head) => head.clone(),
214+ None => return Ok(None),
215+ },
216+ };
217+ let source = pull.fork.clone().unwrap_or_else(|| path.clone());
218+ Some(Subject {
219+ source: if data["commit"].is_string() { path.clone() } else { source },
220+ source_ref: Some(sha.clone()),
221+ git_ref: format!("refs/pull/{}/merge", pull.number),
222+ sha,
223+ head_ref: Some(head_ref),
224+ base_ref: Some(repo.default_branch.clone()),
225+ pull: Some(pull.number),
226+ filter_ref: format!("refs/heads/{}", repo.default_branch),
227+ paths: Some(pull.files.iter().map(|f| f.path.clone()).collect()),
228+ compare: None,
229+ payload,
230+ title: pull.title.clone(),
231+ trusted,
232+ })
233+ }
234+ "issues" | "issue_comment" => {
235+ let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
236+ let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
237+ let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
238+ let (issue_json, comments, title, on_pull) = match issue {
239+ Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false),
240+ Outcome::Fail(_) => {
241+ let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
242+ let Outcome::Ok(detail) = pull else { return Ok(None) };
243+ let labels = detail.issue.as_ref().map(|i| i.labels.clone()).unwrap_or_default();
244+ (payload::pull_as_issue(repo, &detail.pull, &labels), detail.comments, detail.pull.title.clone(), true)
245+ }
246+ };
247+ let mut payload = json!({
248+ "action": action,
249+ "issue": issue_json,
250+ "repository": payload::repository(repo),
251+ "sender": payload::user(sender),
252+ });
253+ if event_name == "issue_comment" {
254+ let comment_id = data["commentId"].as_str();
255+ let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id).or(comments.last());
256+ match comment {
257+ Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
258+ None => return Ok(None),
259+ }
260+ }
261+ Some(on_default(sha, payload, title, on_pull.then_some(number)))
262+ }
263+ _ => None,
264+ })
265+ }
266+
267+ pub async fn on_event(&self, event: &Event) -> Result<()> {
268+ let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
269+ let mapped = github_events(&event.kind);
270+ let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true);
271+ if mapped.is_empty() && !pushed_default {
272+ return Ok(());
273+ }
274+ let Some((repo, ws)) = self.repo_by_id(repo_id).await? else { return Ok(()) };
275+ if pushed_default {
276+ self.sync(&repo, &ws).await?;
277+ }
278+ let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
279+ for (event_name, action) in mapped {
280+ let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
281+ continue;
282+ };
283+ let read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?;
284+ self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &event.id, event.actor.as_deref(), &sender)
285+ .await?;
286+ }
287+ Ok(())
288+ }
289+
290+ #[allow(clippy::too_many_arguments)]
291+ async fn start_matching(
292+ &self,
293+ repo: &Repo,
294+ ws: &User,
295+ read: Read,
296+ subject: &mut Subject,
297+ event_name: &str,
298+ action: Option<&str>,
299+ event_key: &str,
300+ actor_id: Option<&str>,
301+ sender: &str,
302+ ) -> Result<()> {
303+ for file in read.files {
304+ let parsed = workflow::parse(&file.source);
305+ let workflow = match parsed {
306+ Ok(workflow) => workflow,
307+ Err(problem) => {
308+ // A push shows a broken workflow as a failed run, as GitHub does.
309+ if event_name == "push" && file.source.contains("on") {
310+ self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
311+ .await?;
312+ }
313+ continue;
314+ }
315+ };
316+ let Some(trigger) = workflow.trigger(event_name) else { continue };
317+ if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
318+ continue;
319+ }
320+ if self.disabled(&repo.id, &file.path).await? {
321+ continue;
322+ }
323+ self.create_run(NewRun {
324+ repo: repo.clone(),
325+ path: file.path,
326+ source: file.source,
327+ info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id),
328+ workflow,
329+ action: action.map(str::to_owned),
330+ pull: subject.pull,
331+ title: subject.title.clone(),
332+ inputs: Map::new(),
333+ event_key: event_key.to_owned(),
334+ actor_id: actor_id.map(str::to_owned),
335+ actor: Some(sender.to_owned()),
336+ trusted: subject.trusted,
337+ })
338+ .await?;
339+ }
340+ Ok(())
341+ }
342+
343+ /// Whether the branch, tag and path filters let the event through.
344+ async fn passes(&self, repo: &Repo, ws: &User, trigger: &Trigger, subject: &mut Subject, event_name: &str) -> Result<bool> {
345+ let git_ref = subject.filter_ref.as_str();
346+ if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
347+ // A tag push runs a workflow that filters tags, or filters nothing.
348+ if trigger.tags.is_set() {
349+ if !trigger.tags.allows(tag) {
350+ return Ok(false);
351+ }
352+ } else if trigger.branches.is_set() {
353+ return Ok(false);
354+ }
355+ // Paths are not checked for tags, as on GitHub.
356+ return Ok(true);
357+ }
358+ let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref);
359+ if trigger.branches.is_set() {
360+ if !trigger.branches.allows(branch) {
361+ return Ok(false);
362+ }
363+ } else if event_name == "push" && trigger.tags.is_set() {
364+ return Ok(false);
365+ }
366+ if trigger.paths.is_set() {
367+ if subject.paths.is_none() {
368+ let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
369+ subject.paths = Some(self.changed_paths(repo, ws, base, head).await?);
370+ }
371+ if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
372+ return Ok(false);
373+ }
374+ }
375+ Ok(true)
376+ }
377+
378+ async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> {
379+ let row = self
380+ .db
381+ .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
382+ .bind(&[repo_id.into(), path.into()])?
383+ .first::<WorkflowRow>(None)
384+ .await?;
385+ Ok(row.is_some_and(|row| row.state == "disabled"))
386+ }
387+
388+ fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo {
389+ RunInfo {
390+ repository: format!("{}/{}", repo.namespace, repo.name),
391+ repository_id: repo.id.clone(),
392+ default_branch: repo.default_branch.clone(),
393+ event_name: event_name.to_owned(),
394+ event: subject.payload.clone(),
395+ git_ref: subject.git_ref.clone(),
396+ sha: subject.sha.clone(),
397+ head_ref: subject.head_ref.clone(),
398+ base_ref: subject.base_ref.clone(),
399+ actor: sender.to_owned(),
400+ actor_id: actor_id.unwrap_or_default().to_owned(),
401+ triggering_actor: sender.to_owned(),
402+ run_id: String::new(),
403+ run_number: 0,
404+ run_attempt: 1,
405+ workflow: workflow.name.clone().unwrap_or_default(),
406+ workflow_path: String::new(),
407+ server_url: SITE.to_owned(),
408+ api_url: API.to_owned(),
409+ }
410+ }
411+
412+ #[allow(clippy::too_many_arguments)]
413+ async fn record_invalid(
414+ &self,
415+ repo: &Repo,
416+ path: &str,
417+ source: &str,
418+ subject: &Subject,
419+ event_key: &str,
420+ actor_id: Option<&str>,
421+ sender: &str,
422+ problem: &str,
423+ ) -> Result<()> {
424+ let row = self.workflow_row(repo, path, path, source).await?;
425+ self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
426+ }
427+
428+ /// Scheduled workflows whose cron fires this minute, on the default branch.
429+ pub async fn run_schedules(&self, minute: u64) -> Result<()> {
430+ let rows = self
431+ .db
432+ .prepare("SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL")
433+ .all()
434+ .await?
435+ .results::<WorkflowRow>()?;
436+ for row in rows {
437+ let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
438+ let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.fires_at(minute))) else {
439+ continue;
440+ };
441+ let Ok(workflow) = workflow::parse(&row.source) else { continue };
442+ let Some((repo, _ws)) = self.repo_by_id(&row.repo_id).await? else { continue };
443+ let Some(sha) = self.default_head(&repo).await? else { continue };
444+ let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
445+ let mut subject = Subject {
446+ source: Self::repo_path(&repo),
447+ source_ref: None,
448+ git_ref: format!("refs/heads/{}", repo.default_branch),
449+ sha,
450+ head_ref: None,
451+ base_ref: None,
452+ pull: None,
453+ filter_ref: String::new(),
454+ paths: None,
455+ compare: None,
456+ payload,
457+ title: format!("Scheduled: {cron}"),
458+ trusted: true,
459+ };
460+ subject.filter_ref = subject.git_ref.clone();
461+ let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
462+ self.create_run(NewRun {
463+ repo: repo.clone(),
464+ path: row.path.clone(),
465+ source: row.source.clone(),
466+ workflow,
467+ info,
468+ action: None,
469+ pull: None,
470+ title: subject.title.clone(),
471+ inputs: Map::new(),
472+ event_key: format!("schedule:{minute}"),
473+ actor_id: None,
474+ actor: None,
475+ trusted: true,
476+ })
477+ .await?;
478+ }
479+ Ok(())
480+ }
481+
482+ /// `dispatch`: a member runs a workflow that has `workflow_dispatch`.
483+ pub async fn dispatch(&self, a: DispatchArgs) -> Result<Outcome<WorkflowRun>> {
484+ if let Some(refused) = Self::member(&a.actor, &a.repo) {
485+ return Ok(check_refusal(refused));
486+ }
487+ let Some(repo) = self.visible_repo(&a.repo, &Some(a.actor.clone())).await? else {
488+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
489+ };
490+ let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
491+ return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
492+ };
493+ let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
494+ let full_ref = if git_ref.starts_with("refs/") {
495+ git_ref.clone()
496+ } else {
497+ // A branch if there is one by that name, otherwise a tag.
498+ let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
499+ &self.repos,
500+ "branches",
501+ &g1t_contracts::repos::BranchesArgs {
502+ path: Self::repo_path(&repo),
503+ viewer: Some(ws.clone()),
504+ },
505+ )
506+ .await?;
507+ let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
508+ format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
509+ };
510+ let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
511+ let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&short)).await?;
512+ let Some(sha) = read.head.clone() else {
513+ return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
514+ };
515+ let wanted = a.workflow.trim_start_matches(".github/workflows/");
516+ let Some(file) = read.files.iter().find(|file| {
517+ file.path.rsplit('/').next() == Some(wanted) || file.path == a.workflow
518+ }) else {
519+ return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
520+ };
521+ let workflow = match workflow::parse(&file.source) {
522+ Ok(workflow) => workflow,
523+ Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
524+ };
525+ let Some(trigger) = workflow.trigger("workflow_dispatch") else {
526+ return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
527+ };
528+ let inputs = check!(dispatch_inputs(trigger, &a.inputs));
529+ let payload = json!({
530+ "inputs": inputs,
531+ "ref": full_ref,
532+ "repository": payload::repository(&repo),
533+ "sender": payload::user(&a.actor.username),
534+ "workflow": file.path,
535+ });
536+ let subject = Subject {
537+ source: Self::repo_path(&repo),
538+ source_ref: Some(sha.clone()),
539+ git_ref: full_ref.clone(),
540+ sha,
541+ head_ref: None,
542+ base_ref: None,
543+ pull: None,
544+ filter_ref: full_ref,
545+ paths: None,
546+ compare: None,
547+ payload,
548+ title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
549+ trusted: true,
550+ };
551+ let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
552+ let created = self
553+ .create_run(NewRun {
554+ repo: repo.clone(),
555+ path: file.path.clone(),
556+ source: file.source.clone(),
557+ workflow,
558+ info,
559+ action: None,
560+ pull: None,
561+ title: subject.title.clone(),
562+ inputs,
563+ event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
564+ actor_id: Some(a.actor.id.clone()),
565+ actor: Some(a.actor.username.clone()),
566+ trusted: true,
567+ })
568+ .await?;
569+ match created {
570+ Some(id) => self.run_summary(&id).await,
571+ None => Ok(fail(FailureCode::Conflict, "It did not start.")),
572+ }
573+ }
574+}
575+
576+fn check_refusal<T>(refused: Outcome<()>) -> Outcome<T> {
577+ match refused {
578+ Outcome::Fail(failure) => Outcome::Fail(failure),
579+ Outcome::Ok(()) => fail(FailureCode::Forbidden, "Not allowed."),
580+ }
581+}
582+
583+/// The inputs of a manual run: what was given, checked against the
584+/// workflow's declared inputs, with their defaults filled in.
585+fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>) -> Outcome<Map<String, Value>> {
586+ let mut inputs = Map::new();
587+ for (name, spec) in &trigger.inputs {
588+ let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
589+ let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
590+ let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
591+ let value = match value {
592+ Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
593+ Some(Value::Null) | None => match kind {
594+ "boolean" => Value::Bool(false),
595+ _ => Value::String(String::new()),
596+ },
597+ Some(value) => match kind {
598+ "boolean" => Value::Bool(match &value {
599+ Value::Bool(flag) => *flag,
600+ Value::String(text) => text == "true",
601+ _ => false,
602+ }),
603+ "number" => match &value {
604+ Value::Number(_) => value,
605+ Value::String(text) => match text.parse::<f64>().ok().and_then(serde_json::Number::from_f64) {
606+ Some(number) => Value::Number(number),
607+ None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
608+ },
609+ _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
610+ },
611+ "choice" => {
612+ let text = g1t_actions::expr::to_text(&value);
613+ let options: Vec<String> =
614+ spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
615+ if !options.is_empty() && !options.contains(&text) {
616+ return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
617+ }
618+ Value::String(text)
619+ }
620+ _ => Value::String(g1t_actions::expr::to_text(&value)),
621+ },
622+ };
623+ inputs.insert(name.clone(), value);
624+ }
625+ Outcome::Ok(inputs)
626+}
+216−0
1+//! Reading: workflows, runs, a run's jobs, and a job's log.
2+
3+use g1t_actions::workflow::{self, Severity};
4+use g1t_contracts::actions::{
5+ Annotation, Job, JobLog, LogChunk, LogsArgs, RunArgs, RunDetail, RunsArgs, SetWorkflowEnabledArgs, StepState, Workflow, WorkflowNote,
6+ WorkflowRun, WorkflowsArgs,
7+};
8+use g1t_contracts::{FailureCode, Outcome};
9+use serde::Deserialize;
10+use worker::Result;
11+
12+use crate::plan::{JobRow, RunRow};
13+use crate::sync::WorkflowRow;
14+use crate::{Actions, check, fail};
15+
16+const RUNS_SHOWN: u32 = 50;
17+
18+fn notes(source: &str) -> Vec<WorkflowNote> {
19+ workflow::parse(source)
20+ .map(|w| {
21+ w.notes
22+ .into_iter()
23+ .map(|note| WorkflowNote {
24+ severity: match note.severity {
25+ Severity::Info => "info",
26+ Severity::Warning => "warning",
27+ Severity::Unsupported => "unsupported",
28+ }
29+ .to_owned(),
30+ job: note.job,
31+ message: note.message,
32+ })
33+ .collect()
34+ })
35+ .unwrap_or_default()
36+}
37+
38+fn job_view(row: JobRow) -> Job {
39+ let needs = row.needs();
40+ Job {
41+ id: row.id,
42+ run_id: row.run_id,
43+ key: row.key,
44+ name: row.name,
45+ needs,
46+ status: row.status,
47+ conclusion: row.conclusion,
48+ steps: serde_json::from_str::<Vec<StepState>>(&row.steps).unwrap_or_default(),
49+ annotations: serde_json::from_str::<Vec<Annotation>>(&row.annotations).unwrap_or_default(),
50+ reason: row.reason,
51+ started_at: row.started_at,
52+ finished_at: row.finished_at,
53+ }
54+}
55+
56+impl Actions {
57+ async fn summary(&self, row: &WorkflowRow) -> Result<Workflow> {
58+ let last_run = self
59+ .db
60+ .prepare("SELECT * FROM runs WHERE workflow_id = ? ORDER BY id DESC LIMIT 1")
61+ .bind(&[row.id.as_str().into()])?
62+ .first::<RunRow>(None)
63+ .await?
64+ .map(|run| run.summary());
65+ let parsed = workflow::parse(&row.source).ok();
66+ Ok(Workflow {
67+ id: row.id.clone(),
68+ path: row.path.clone(),
69+ name: row.name.clone(),
70+ events: serde_json::from_str(&row.events).unwrap_or_default(),
71+ state: row.state.clone(),
72+ error: row.error.clone(),
73+ notes: notes(&row.source),
74+ dispatch: parsed
75+ .as_ref()
76+ .filter(|_| row.error.is_none())
77+ .and_then(|w| w.trigger("workflow_dispatch"))
78+ .map(|t| serde_json::Value::Object(t.inputs.clone())),
79+ last_run,
80+ })
81+ }
82+
83+ pub async fn workflows(&self, a: WorkflowsArgs) -> Result<Outcome<Vec<Workflow>>> {
84+ let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
85+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
86+ };
87+ if !self.synced(&repo.id).await?
88+ && let Some(ws) = self.workspace_actor(&repo.namespace).await?
89+ {
90+ self.sync(&repo, &ws).await?;
91+ }
92+ let rows = self
93+ .db
94+ .prepare(
95+ "SELECT * FROM workflows WHERE repo_id = ?
96+ AND (error IS NULL OR error NOT LIKE 'Its file is%' OR id IN (SELECT workflow_id FROM runs WHERE repo_id = ?))
97+ ORDER BY name",
98+ )
99+ .bind(&[repo.id.as_str().into(), repo.id.as_str().into()])?
100+ .all()
101+ .await?
102+ .results::<WorkflowRow>()?;
103+ let mut out = Vec::with_capacity(rows.len());
104+ for row in &rows {
105+ out.push(self.summary(row).await?);
106+ }
107+ Ok(Outcome::Ok(out))
108+ }
109+
110+ pub async fn runs(&self, a: RunsArgs) -> Result<Outcome<Vec<WorkflowRun>>> {
111+ let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
112+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
113+ };
114+ let mut sql = "SELECT * FROM runs WHERE repo_id = ?".to_owned();
115+ let mut binds: Vec<worker::wasm_bindgen::JsValue> = vec![repo.id.as_str().into()];
116+ if let Some(workflow) = &a.workflow {
117+ sql.push_str(" AND (workflow_id = ? OR path = ? OR path = ?)");
118+ binds.push(workflow.as_str().into());
119+ binds.push(workflow.as_str().into());
120+ binds.push(format!("{}/{workflow}", g1t_actions::workflow::FOLDER).into());
121+ }
122+ if let Some(branch) = &a.branch {
123+ sql.push_str(" AND (git_ref = ? OR json_extract(info, '$.headRef') = ?)");
124+ binds.push(format!("refs/heads/{branch}").into());
125+ binds.push(branch.as_str().into());
126+ }
127+ if let Some(event) = &a.event {
128+ sql.push_str(" AND event = ?");
129+ binds.push(event.as_str().into());
130+ }
131+ if let Some(pull) = a.pull {
132+ sql.push_str(" AND pull = ?");
133+ binds.push(pull.into());
134+ }
135+ if let Some(sha) = &a.sha {
136+ sql.push_str(" AND sha = ?");
137+ binds.push(sha.as_str().into());
138+ }
139+ sql.push_str(" ORDER BY id DESC LIMIT ?");
140+ binds.push(a.limit.unwrap_or(RUNS_SHOWN).clamp(1, 100).into());
141+ let rows = self.db.prepare(sql).bind(&binds)?.all().await?.results::<RunRow>()?;
142+ Ok(Outcome::Ok(rows.iter().map(RunRow::summary).collect()))
143+ }
144+
145+ pub async fn run(&self, a: RunArgs) -> Result<Outcome<RunDetail>> {
146+ if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
147+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
148+ }
149+ let run = check!(self.run_in(&a.repo, &a.id).await?);
150+ let jobs = self.job_rows(&run.id).await?.into_iter().map(job_view).collect();
151+ Ok(Outcome::Ok(RunDetail {
152+ notes: notes(&run.source),
153+ run: run.summary(),
154+ jobs,
155+ }))
156+ }
157+
158+ pub async fn logs(&self, a: LogsArgs) -> Result<Outcome<JobLog>> {
159+ if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
160+ return Ok(fail(FailureCode::NotFound, "There is no such repository."));
161+ }
162+ let job = self
163+ .db
164+ .prepare("SELECT jobs.* FROM jobs JOIN runs ON runs.id = jobs.run_id WHERE jobs.id = ? AND lower(runs.repo) = lower(?)")
165+ .bind(&[a.job.as_str().into(), format!("{}/{}", a.repo.namespace, a.repo.name).into()])?
166+ .first::<JobRow>(None)
167+ .await?;
168+ let Some(job) = job else {
169+ return Ok(fail(FailureCode::NotFound, "No such job."));
170+ };
171+ #[derive(Deserialize)]
172+ struct Row {
173+ seq: u64,
174+ step: u32,
175+ text: String,
176+ }
177+ let chunks = self
178+ .db
179+ .prepare("SELECT seq, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 500")
180+ .bind(&[job.id.as_str().into(), (a.after as f64).into()])?
181+ .all()
182+ .await?
183+ .results::<Row>()?
184+ .into_iter()
185+ .map(|row| LogChunk { seq: row.seq, step: row.step, text: row.text })
186+ .collect();
187+ Ok(Outcome::Ok(JobLog {
188+ chunks,
189+ done: job.status == "completed",
190+ }))
191+ }
192+
193+ pub async fn set_workflow_enabled(&self, a: SetWorkflowEnabledArgs) -> Result<Outcome<Workflow>> {
194+ if let Some(Outcome::Fail(refused)) = Self::member(&a.actor, &a.repo) {
195+ return Ok(Outcome::Fail(refused));
196+ }
197+ let wanted = a.workflow.trim_start_matches(".github/workflows/");
198+ let row = self
199+ .db
200+ .prepare("SELECT * FROM workflows WHERE lower(repo) = lower(?) AND (id = ? OR path = ?)")
201+ .bind(&[
202+ format!("{}/{}", a.repo.namespace, a.repo.name).into(),
203+ a.workflow.as_str().into(),
204+ format!("{}/{wanted}", g1t_actions::workflow::FOLDER).into(),
205+ ])?
206+ .first::<WorkflowRow>(None)
207+ .await?;
208+ let Some(row) = row else {
209+ return Ok(fail(FailureCode::NotFound, "No such workflow."));
210+ };
211+ let state = if a.enabled { "active" } else { "disabled" };
212+ self.db.prepare("UPDATE workflows SET state = ? WHERE id = ?").bind(&[state.into(), row.id.as_str().into()])?.run().await?;
213+ let row = WorkflowRow { state: state.to_owned(), ..row };
214+ Ok(Outcome::Ok(self.summary(&row).await?))
215+ }
216+}
+35−0
1+{
2+ "$schema": "../../node_modules/wrangler/config-schema.json",
3+ "name": "g1t-actions",
4+ "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
5+ "compatibility_date": "2026-09-26",
6+ // Runs next to its database: moving a run along makes many queries in turn.
7+ "placement": { "mode": "smart" },
8+ "main": "build/index.js",
9+ "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10+ // Reached only through service bindings.
11+ "workers_dev": false,
12+ "d1_databases": [
13+ {
14+ "binding": "DB",
15+ "database_name": "g1t-actions",
16+ "database_id": "95ccaa88-8e15-4b90-81e2-0d02d8ce869d",
17+ "migrations_dir": "migrations"
18+ }
19+ ],
20+ // ACTIONS_KEY (secret): seals secrets at rest. Never regenerate it:
21+ // secrets saved under the old key could no longer be opened.
22+ "services": [
23+ { "binding": "REPOS", "service": "g1t-repos" },
24+ { "binding": "WORK", "service": "g1t-work" },
25+ { "binding": "IDENTITY", "service": "g1t-identity" },
26+ { "binding": "RUNNER", "service": "g1t-runner" }
27+ ],
28+ // Every event on the bus: what starts workflows, and pushes that change them.
29+ "queues": {
30+ "consumers": [{ "queue": "g1t-events-actions", "max_batch_size": 10, "max_batch_timeout": 1, "max_retries": 3 }]
31+ },
32+ // Schedules, jobs waiting for room, and jobs whose runner went quiet.
33+ "triggers": { "crons": ["* * * * *"] },
34+ "observability": { "enabled": true }
35+}
+1−0
99 crate-type = ["cdylib"]
1010
1111 [dependencies]
12+g1t-actions.workspace = true
1213 g1t-contracts.workspace = true
1314 g1t-kit.workspace = true
1415 serde.workspace = true
+0−170
1−//! Five-field cron schedules, in UTC: minute, hour, day of month, month,
2−//! day of week. Each field takes `*`, a number, a range `a-b`, a list
3−//! `a,b`, and a step `*/n` or `a-b/n`; days of the week also take `mon` to
4−//! `sun`.
5−
6−#[derive(Clone, Debug, PartialEq, Eq)]
7−pub struct Schedule {
8− minutes: Vec<bool>,
9− hours: Vec<bool>,
10− days: Vec<bool>,
11− months: Vec<bool>,
12− weekdays: Vec<bool>,
13− /// Whether day of month and day of week were each restricted: when both
14− /// are, either matching is enough, as in every cron.
15− days_restricted: bool,
16− weekdays_restricted: bool,
17−}
18−
19−const WEEKDAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
20−
21−fn field(text: &str, low: u32, high: u32, names: &[&str]) -> Result<(Vec<bool>, bool), String> {
22− let mut set = vec![false; (high + 1) as usize];
23− let value = |part: &str| -> Result<u32, String> {
24− if let Some(at) = names.iter().position(|name| part.eq_ignore_ascii_case(name)) {
25− return Ok(at as u32);
26− }
27− part.parse::<u32>().map_err(|_| format!("`{part}` is not a number"))
28− };
29− for item in text.split(',') {
30− let (range, step) = match item.split_once('/') {
31− Some((range, step)) => (range, step.parse::<u32>().map_err(|_| format!("`{step}` is not a step"))?),
32− None => (item, 1),
33− };
34− if step == 0 {
35− return Err("a step cannot be 0".to_owned());
36− }
37− let (from, to) = if range == "*" {
38− (low, high)
39− } else if let Some((a, b)) = range.split_once('-') {
40− (value(a)?, value(b)?)
41− } else {
42− let at = value(range)?;
43− (at, if item.contains('/') { high } else { at })
44− };
45− // Sunday may be written 7.
46− let (from, to) = if names.len() == 7 && to == 7 { (from.min(6), 6) } else { (from, to) };
47− if from < low || to > high || from > to {
48− return Err(format!("`{item}` is outside {low}-{high}"));
49− }
50− let mut at = from;
51− while at <= to {
52− set[at as usize] = true;
53− at += step;
54− }
55− if names.len() == 7 && text.split(',').any(|part| part == "7") {
56− set[0] = true;
57− }
58− }
59− Ok((set, text != "*"))
60−}
61−
62−impl Schedule {
63− pub fn parse(text: &str) -> Result<Schedule, String> {
64− let parts: Vec<&str> = text.split_whitespace().collect();
65− let [minute, hour, day, month, weekday] = parts[..] else {
66− return Err("a schedule has five fields: minute hour day month weekday, such as `0 9 * * mon`".to_owned());
67− };
68− let (minutes, _) = field(minute, 0, 59, &[])?;
69− let (hours, _) = field(hour, 0, 23, &[])?;
70− let (days, days_restricted) = field(day, 1, 31, &[])?;
71− let (months, _) = field(month, 1, 12, &[])?;
72− let (weekdays, weekdays_restricted) = field(weekday, 0, 6, &WEEKDAYS)?;
73− Ok(Schedule {
74− minutes,
75− hours,
76− days,
77− months,
78− weekdays,
79− days_restricted,
80− weekdays_restricted,
81− })
82− }
83−
84− /// Whether it fires in the minute starting at `ms` since the epoch, UTC.
85− pub fn fires_at(&self, ms: u64) -> bool {
86− let minutes_total = ms / 60_000;
87− let minute = (minutes_total % 60) as usize;
88− let hour = (minutes_total / 60 % 24) as usize;
89− let days_since_epoch = (minutes_total / 60 / 24) as i64;
90− // 1970-01-01 was a Thursday.
91− let weekday = ((days_since_epoch + 4) % 7) as usize;
92− let (_, month, day) = civil_from_days(days_since_epoch);
93− let day_ok = self.days[day as usize];
94− let weekday_ok = self.weekdays[weekday];
95− let date_ok = match (self.days_restricted, self.weekdays_restricted) {
96− (true, true) => day_ok || weekday_ok,
97− _ => day_ok && weekday_ok,
98− };
99− self.minutes[minute] && self.hours[hour] && self.months[month as usize] && date_ok
100− }
101−}
102−
103−/// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm).
104−fn civil_from_days(days: i64) -> (i64, u32, u32) {
105− let z = days + 719_468;
106− let era = z.div_euclid(146_097);
107− let doe = z.rem_euclid(146_097);
108− let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
109− let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
110− let mp = (5 * doy + 2) / 153;
111− let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
112− let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
113− (yoe + era * 400 + i64::from(month <= 2), month, day)
114−}
115−
116−#[cfg(test)]
117−mod tests {
118− use super::*;
119−
120− /// Milliseconds at a UTC date and time.
121− fn at(days_since_epoch: u64, hour: u64, minute: u64) -> u64 {
122− ((days_since_epoch * 24 + hour) * 60 + minute) * 60_000
123− }
124−
125− // 2026-10-05 is a Monday: 20_731 days after 1970-01-01.
126− const MONDAY: u64 = 20_731;
127−
128− #[test]
129− fn dates_are_worked_out() {
130− assert_eq!(civil_from_days(0), (1970, 1, 1));
131− assert_eq!(civil_from_days(MONDAY as i64), (2026, 10, 5));
132− }
133−
134− #[test]
135− fn mondays_at_nine() {
136− let schedule = Schedule::parse("0 9 * * mon").unwrap();
137− assert!(schedule.fires_at(at(MONDAY, 9, 0)));
138− assert!(!schedule.fires_at(at(MONDAY, 9, 1)));
139− assert!(!schedule.fires_at(at(MONDAY + 1, 9, 0)));
140− assert!(Schedule::parse("0 9 * * 1").unwrap().fires_at(at(MONDAY, 9, 0)));
141− }
142−
143− #[test]
144− fn steps_ranges_and_lists() {
145− let every_quarter = Schedule::parse("*/15 * * * *").unwrap();
146− assert!(every_quarter.fires_at(at(MONDAY, 3, 45)));
147− assert!(!every_quarter.fires_at(at(MONDAY, 3, 44)));
148− let weekdays = Schedule::parse("30 8-17/3 * * mon-fri").unwrap();
149− assert!(weekdays.fires_at(at(MONDAY, 14, 30)));
150− assert!(!weekdays.fires_at(at(MONDAY, 15, 30)));
151− assert!(!weekdays.fires_at(at(MONDAY + 5, 14, 30)));
152− let sunday = Schedule::parse("0 0 * * 7").unwrap();
153− assert!(sunday.fires_at(at(MONDAY + 6, 0, 0)));
154− }
155−
156− #[test]
157− fn day_of_month_or_week_when_both_are_given() {
158− // The 1st, or any Monday.
159− let schedule = Schedule::parse("0 0 1 * mon").unwrap();
160− assert!(schedule.fires_at(at(MONDAY, 0, 0)));
161− assert!(!schedule.fires_at(at(MONDAY + 1, 0, 0)));
162− }
163−
164− #[test]
165− fn nonsense_is_refused() {
166− for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] {
167− assert!(Schedule::parse(text).is_err(), "{text}");
168− }
169− }
170−}
+1−1
66 use g1t_contracts::webhooks::EVENT_TYPES;
77 use serde_yaml::Value;
88
9−use crate::cron::Schedule;
9+use g1t_actions::cron::Schedule;
1010
1111 /// What starts an automation.
1212 #[derive(Clone, Debug)]
+0−1
1313 //! Automations act as their workspace: what they write is the workspace's,
1414 //! and says which automation wrote it.
1515
16−mod cron;
1716 mod definition;
1817
1918 use g1t_contracts::automations::*;
+2−1
2727 { "binding": "SUBSCRIBER_RUNNER", "queue": "g1t-events-runner" },
2828 { "binding": "SUBSCRIBER_INTEGRATIONS", "queue": "g1t-events-integrations" },
2929 { "binding": "SUBSCRIBER_WEBHOOKS", "queue": "g1t-events-webhooks" },
30− { "binding": "SUBSCRIBER_AUTOMATIONS", "queue": "g1t-events-automations" }
30+ { "binding": "SUBSCRIBER_AUTOMATIONS", "queue": "g1t-events-automations" },
31+ { "binding": "SUBSCRIBER_ACTIONS", "queue": "g1t-events-actions" }
3132 ],
3233 "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1 }]
3334 },
+11−3
1−# The sandbox a g1t agent works in: git, the agent, the g1t runner, and
2−# the toolchains an agent needs to build and test what it changes.
1+# The sandbox a g1t agent works in, and where GitHub Actions jobs run:
2+# git, the agent, the g1t runner, and the toolchains an agent or a
3+# workflow needs to build and test a change.
34 # Build context: the repository root.
45
56 # The same Debian release as the runtime image, so glibc matches.
1314 RUN cargo build --release --package g1t-runner
1415
1516 FROM node:22-bookworm-slim
17+# Workflows expect GitHub's runner layout under /home/runner, and sudo
18+# without a password.
1619 RUN apt-get update \
1720 && apt-get install -y --no-install-recommends \
1821 git ca-certificates curl build-essential pkg-config libssl-dev \
1922 python3 python3-pip python3-venv golang-go ripgrep jq \
23+ sudo unzip zip xz-utils wget file gnupg lsb-release \
2024 && rm -rf /var/lib/apt/lists/* \
2125 && npm install --global @anthropic-ai/claude-code \
22− && mkdir /work && chown node:node /work
26+ && mkdir /work && chown node:node /work \
27+ && mkdir -p /home/runner/work /home/runner/_temp /home/runner/_tool /home/runner/_actions \
28+ && chown -R node:node /home/runner \
29+ && echo 'node ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/node \
30+ && chmod 0440 /etc/sudoers.d/node
2331 COPY --from=build /src/target/release/g1t-runner /usr/local/bin/g1t-runner
2432 # Claude Code refuses to skip permission prompts as root.
2533 USER node
+76−2
3838 WORK: ServiceBinding;
3939 BILLING: ServiceBinding;
4040 INTEGRATIONS: ServiceBinding;
41+ /** GitHub Actions jobs: told when a job's sandbox dies without reporting. */
42+ ACTIONS: ServiceBinding;
4143 /**
4244 * The model proxy, which every sandbox's model requests go through with a
4345 * token for their run, so that no sandbox holds a key. When unset,
98100 /** An agent turning an outcome into a plan. */
99101 | { kind: "plan"; planId: string; token: string }
100102 /** One combined state of a merge queue, being built and checked. */
101− | { kind: "queue"; entryId: string; token: string };
103+ | { kind: "queue"; entryId: string; token: string }
104+ /** One job of a GitHub Actions workflow. */
105+ | { kind: "actions"; jobId: string; token: string };
102106 type RunRequest = Run & { envVars: Record<string, string> };
103107
104108 /** Long enough to clone, install and test; then the token stops working. */
122126 if (exitCode === 0) return;
123127 const run = await this.ctx.storage.get<Run>("run");
124128 if (!run) return;
129+ if (run.kind === "actions") {
130+ // Refused harmlessly if the job reported its end before it stopped.
131+ await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
132+ method: "POST",
133+ headers: { "content-type": "application/json" },
134+ body: JSON.stringify({
135+ job: run.jobId,
136+ token: run.token,
137+ report: { kind: "done", conclusion: "failure", reason: "The runner stopped before the job finished." },
138+ }),
139+ });
140+ return;
141+ }
125142 const work = workClient(this.env.WORK);
126143 if (run.kind === "checks") {
127144 // Refused harmlessly if the run did report before it stopped.
226243 "answer_message",
227244 // Tickets and alerts outside g1t, through the workspace's integrations.
228245 "get_context",
246+ // GitHub Actions: how the workflows went on its change, and why.
247+ "list_workflows",
248+ "list_workflow_runs",
249+ "get_workflow_run",
250+ "get_job_logs",
229251 ];
230252
231253 /** How an agent is told to use g1t's tools to work with the others. */
232254 const WORKING_WITH_OTHERS =
233− "You have g1t's own tools (mcp__g1t__…) for this repository. Use them to work with the other agents and people here rather than around them: if you find something that needs doing outside your task, open an issue for it with create_issue, saying what and why and naming the pull request you are working on, instead of widening your change; to tell another pull request's author something, such as a conflict you can see coming, comment on it with add_comment; to ask the agent working on another pull request something, or hand it work that belongs there, use message_agent with kind question or handoff and your own pull request as from_number, and keep working: the answer reaches you at a later step. Answer what other agents send you with answer_message. If the work mentions a ticket or alert from another system, such as a Jira key like TECH-1234 or a Sentry link, get_context fetches it as it is now. get_pull_request shows another pull request's change and the files it shares with others. Mention anything you opened, asked or answered in your summary.";
255+ "You have g1t's own tools (mcp__g1t__…) for this repository. Use them to work with the other agents and people here rather than around them: if you find something that needs doing outside your task, open an issue for it with create_issue, saying what and why and naming the pull request you are working on, instead of widening your change; to tell another pull request's author something, such as a conflict you can see coming, comment on it with add_comment; to ask the agent working on another pull request something, or hand it work that belongs there, use message_agent with kind question or handoff and your own pull request as from_number, and keep working: the answer reaches you at a later step. Answer what other agents send you with answer_message. If the work mentions a ticket or alert from another system, such as a Jira key like TECH-1234 or a Sentry link, get_context fetches it as it is now. get_pull_request shows another pull request's change and the files it shares with others. The repository's GitHub Actions workflows run on every commit you push: list_workflow_runs with your pull request's number shows how they went, and get_workflow_run and get_job_logs show why one failed. Mention anything you opened, asked or answered in your summary.";
234256
235257 /** Longest that what people said on a pull request is passed on. */
236258 const MAX_PEOPLE_SAID_CHARS = 6000;
357379 await this.run(args.actor, args.repo, args.issue, { instructions: args.instructions }),
358380 );
359381 }
382+ if (request.method === "POST" && pathname === "/rpc/start_actions_job") {
383+ const args = (await request.json()) as {
384+ job: string;
385+ token: string;
386+ repo: RepoPath;
387+ timeoutMinutes: number;
388+ };
389+ return Response.json(await this.startActionsJob(args));
390+ }
391+ if (request.method === "POST" && pathname === "/rpc/stop_actions_job") {
392+ const args = (await request.json()) as { job: string };
393+ const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`actions:${args.job}`));
394+ await sandbox.destroy().catch(() => undefined);
395+ return Response.json(ok(null));
396+ }
360397 if (request.method === "POST" && pathname === "/rpc/plan") {
361398 const args = (await request.json()) as { actor: User; repo: RepoPath; brief: string };
362399 return Response.json(await this.plan(args.actor, args.repo, args.brief));
506543 };
507544 }
508545
546+ /**
547+ * Starts one job of a GitHub Actions workflow in a sandbox of its own.
548+ * The sandbox fetches the job, its contexts and its secrets with the
549+ * job's token, and reports back to the actions service through the API.
550+ * Jobs run on g1t's machines, so only for workspaces that may use them.
551+ */
552+ private async startActionsJob(args: {
553+ job: string;
554+ token: string;
555+ repo: RepoPath;
556+ timeoutMinutes: number;
557+ }): Promise<Result<null>> {
558+ const status = await billingClient(this.env.BILLING).status();
559+ if (!(this.previewListed(args.repo.namespace) || (status.enabled && status.live))) {
560+ return {
561+ ok: false,
562+ error: {
563+ code: "forbidden",
564+ message: "Workflows run on g1t's hosted runners, which are not open to this workspace yet.",
565+ },
566+ };
567+ }
568+ const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`actions:${args.job}`));
569+ await sandbox.run({
570+ kind: "actions",
571+ jobId: args.job,
572+ token: args.token,
573+ envVars: {
574+ MODE: "actions",
575+ G1T_API: "https://api.g1t.sh",
576+ ACTIONS_JOB: args.job,
577+ ACTIONS_TOKEN: args.token,
578+ },
579+ });
580+ return ok(null);
581+ }
582+
509583 /** Whether a workspace's repositories may use g1t's agents and sandboxes at all. */
510584 private async workspaceAllowed(namespace: string): Promise<boolean> {
511585 const access = await this.modelAccess(namespace);
+2−1
2929 { "binding": "REPOS", "service": "g1t-repos" },
3030 { "binding": "WORK", "service": "g1t-work" },
3131 { "binding": "BILLING", "service": "g1t-billing" },
32− { "binding": "INTEGRATIONS", "service": "g1t-integrations" }
32+ { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
33+ { "binding": "ACTIONS", "service": "g1t-actions" }
3334 ],
3435 // A sweep for lifecycle steps whose trigger was missed or whose sandbox
3536 // died before reporting.
+15−0
1+-- Statuses on commits: what a workflow run (or another tool) says about a
2+-- commit. A pull request whose head has a status that is pending or failed
3+-- waits, or is sent back, as for its acceptance checks.
4+CREATE TABLE commit_statuses (
5+ repo_id TEXT NOT NULL,
6+ sha TEXT NOT NULL,
7+ -- What reported it, such as "CI / push".
8+ context TEXT NOT NULL,
9+ -- pending, success, failure or error.
10+ state TEXT NOT NULL,
11+ description TEXT,
12+ target_url TEXT,
13+ updated_at TEXT NOT NULL,
14+ PRIMARY KEY (repo_id, sha, context)
15+);
+6−0
1212 mod reviews;
1313 mod rows;
1414 mod settings;
15+mod statuses;
1516
1617 use g1t_contracts::events::{
1718 CommentCreated, Event, IssueEvent, NewEvent, Publish, PullEvent, SessionAppended,
10631064 landing,
10641065 stalled,
10651066 messages: self.messages(&pull.id).await?,
1067+ statuses: self.statuses(&repo.id, pull.head_commit.as_deref()).await?,
10661068 issue,
10671069 pull,
10681070 }))
12811283 Some(CheckStatus::Errored) => Some("The acceptance checks could not be run."),
12821284 Some(CheckStatus::Passed) | None => None,
12831285 };
1286+ // Workflows run on its head count as checks too.
1287+ let workflows = statuses::WorkflowFacts::of(&self.statuses(&repo.id, pull.head_commit.as_deref()).await?).refusal();
1288+ let waiting = waiting.map(str::to_owned).or(workflows);
12841289 if let Some(reason) = waiting {
12851290 let remedy = if settings.allow_ignoring_checks {
12861291 "Wait or fix them, or merge anyway by ignoring the checks."
17521757 "add_comment" => reply(&work.add_comment(args(body)?).await?),
17531758 "start_checks" => reply(&work.start_checks(args(body)?).await?),
17541759 "report_checks" => reply(&work.report_checks(args(body)?).await?),
1760+ "set_commit_status" => reply(&work.set_commit_status(args(body)?).await?),
17551761 "start_review" => reply(&work.start_review(args(body)?).await?),
17561762 "advance" => reply(&work.advance(args(body)?).await?),
17571763 "stall" => reply(&work.stall(args(body)?).await?),
+76−0
2323
2424 use crate::Work;
2525 use crate::reviews::{AGENT_ID, AGENT_NAME};
26+use crate::statuses::{self, WorkflowFacts};
2627 use crate::rows::ValueRow;
2728
2829 /// How long a claimed step is waited for before it may be taken again.
8081 /// What the author is being sent back to address.
8182 pub(crate) enum Feedback {
8283 FailedChecks,
84+ /// Workflows that failed on its head.
85+ FailedWorkflows,
8386 /// The review that finished at this time.
8487 Review(String),
8588 /// What a person who asked for changes wrote since the last revision.
159162 person_request: Option<PersonRequest>,
160163 /// Its place in the merge queue, and what is ahead of it there.
161164 queued: Option<(QueueState, Vec<u32>)>,
165+ /// What the workflows run on its head say.
166+ workflows: WorkflowFacts,
162167 }
163168
164169 /// Where a pull request stands, and the step to take if it is g1t's turn.
280285 }
281286 }
282287
288+ // Its workflows, like its checks, must pass.
289+ if !facts.workflows.failed.is_empty() {
290+ let failed = statuses::list(&facts.workflows.failed);
291+ if exhausted {
292+ return wait(
293+ Stage::NeedsYou,
294+ &format!("{failed} still fails after the agent revised {}.", times(revisions)),
295+ );
296+ }
297+ return (
298+ at(
299+ Stage::Revising,
300+ format!("{failed} failed. The agent is being sent back to fix it."),
301+ revisions,
302+ ),
303+ Next::Revise(Feedback::FailedWorkflows),
304+ );
305+ }
306+ if !facts.workflows.pending.is_empty() {
307+ return wait(
308+ Stage::Checking,
309+ &format!("Waiting for {} to finish.", statuses::list(&facts.workflows.pending)),
310+ );
311+ }
312+
283313 // A second agent reviews it, unless the repository leaves review to people.
284314 if facts.agent_review {
285315 match facts.review {
462492 .person_request(pull, progress.revised_at.as_deref())
463493 .await?,
464494 queued: self.queued_entry(&pull.id).await?,
495+ workflows: WorkflowFacts::of(&self.statuses(&pull.repo_id, pull.head_commit.as_deref()).await?),
465496 })))
466497 }
467498
588619 failed.join("\n\n")
589620 ))
590621 }
622+ Feedback::FailedWorkflows => {
623+ let statuses = self.statuses(&pull.repo_id, pull.head_commit.as_deref()).await?;
624+ let failed: Vec<String> = statuses
625+ .iter()
626+ .filter(|s| s.state == "failure" || s.state == "error")
627+ .map(|s| {
628+ let run = s.target_url.as_deref().and_then(|url| url.rsplit('/').next()).unwrap_or_default();
629+ format!(
630+ "- {} ({}): run `{run}`",
631+ s.context,
632+ s.description.as_deref().unwrap_or("failed")
633+ )
634+ })
635+ .collect();
636+ Ok(format!(
637+ "These GitHub Actions workflows failed on your latest commit:\n\n{}\n\n\
638+ Read why with the `get_workflow_run` tool (this repository, and the run's id), \
639+ then `get_job_logs` for the job that failed. Fix the cause in the code, not the workflow, \
640+ unless the workflow itself is wrong.",
641+ failed.join("\n")
642+ ))
643+ }
591644 Feedback::Review(finished_at) => {
592645 // Everything a review says is recorded at the moment it finished.
593646 let notes = self
747800 Next::Revise(Feedback::FailedChecks) => {
748801 "sent g1t-agent back to fix the failed checks".to_owned()
749802 }
803+ Next::Revise(Feedback::FailedWorkflows) => {
804+ "sent g1t-agent back to fix the failed workflows".to_owned()
805+ }
750806 Next::Revise(Feedback::Review(_)) => {
751807 "sent g1t-agent back to address the review".to_owned()
752808 }
11391195 approvals_missing: None,
11401196 person_request: None,
11411197 queued: None,
1198+ workflows: WorkflowFacts::default(),
11421199 }
11431200 }
11441201
11451202 #[test]
1203+ fn failed_workflows_send_the_agent_back_and_running_ones_wait() {
1204+ let failed = Facts {
1205+ workflows: WorkflowFacts { pending: vec![], failed: vec!["CI / pull_request".into()] },
1206+ ..facts()
1207+ };
1208+ let (lifecycle, next) = decide(failed);
1209+ assert!(matches!(next, Next::Revise(Feedback::FailedWorkflows)));
1210+ assert!(lifecycle.detail.contains("CI / pull_request failed"));
1211+ let running = Facts {
1212+ workflows: WorkflowFacts { pending: vec!["CI / pull_request".into()], failed: vec![] },
1213+ ..facts()
1214+ };
1215+ let (lifecycle, next) = decide(running);
1216+ assert!(matches!(next, Next::Wait));
1217+ assert!(lifecycle.detail.contains("Waiting for CI / pull_request"));
1218+ }
1219+
1220+ #[test]
11461221 fn a_queued_pull_request_waits_in_the_queue() {
11471222 let queued = Facts {
11481223 review: reviewed(Some(Verdict::Approve)),
12041279 Next::Wait => "wait",
12051280 Next::Review => "review",
12061281 Next::Revise(Feedback::FailedChecks) => "revise for checks",
1282+ Next::Revise(Feedback::FailedWorkflows) => "revise for workflows",
12071283 Next::Revise(Feedback::Review(_)) => "revise for review",
12081284 Next::Revise(Feedback::Person(_)) => "revise for a person",
12091285 Next::CatchUp => "catch up",
+174−0
1+//! Statuses on commits: what workflow runs say about a pull request's head.
2+//! A pending status holds the pull request, a failed one sends its agent
3+//! back (or, for anyone else's, refuses the merge), as acceptance checks do.
4+
5+use g1t_contracts::events::ChecksEvent;
6+use g1t_contracts::time::rfc3339;
7+use g1t_contracts::work::{CommitStatus, SetCommitStatusArgs};
8+use g1t_contracts::{FailureCode, Outcome};
9+use g1t_kit::now_ms;
10+use serde::Deserialize;
11+use worker::Result;
12+
13+use crate::Work;
14+
15+#[derive(Deserialize)]
16+struct StatusRow {
17+ context: String,
18+ state: String,
19+ description: Option<String>,
20+ target_url: Option<String>,
21+ updated_at: String,
22+}
23+
24+impl From<StatusRow> for CommitStatus {
25+ fn from(row: StatusRow) -> Self {
26+ CommitStatus {
27+ context: row.context,
28+ state: row.state,
29+ description: row.description,
30+ target_url: row.target_url,
31+ updated_at: row.updated_at,
32+ }
33+ }
34+}
35+
36+#[derive(Deserialize)]
37+struct HeadRow {
38+ id: String,
39+ number: u32,
40+}
41+
42+/// The workflows still running and the ones that failed, by name.
43+#[derive(Clone, Debug, Default, PartialEq, Eq)]
44+pub(crate) struct WorkflowFacts {
45+ pub(crate) pending: Vec<String>,
46+ pub(crate) failed: Vec<String>,
47+}
48+
49+impl WorkflowFacts {
50+ pub(crate) fn of(statuses: &[CommitStatus]) -> WorkflowFacts {
51+ WorkflowFacts {
52+ pending: statuses.iter().filter(|s| s.state == "pending").map(|s| s.context.clone()).collect(),
53+ failed: statuses.iter().filter(|s| s.state == "failure" || s.state == "error").map(|s| s.context.clone()).collect(),
54+ }
55+ }
56+
57+ /// Why a merge has to wait, if it does.
58+ pub(crate) fn refusal(&self) -> Option<String> {
59+ if !self.failed.is_empty() {
60+ return Some(format!("{} failed.", list(&self.failed)));
61+ }
62+ if !self.pending.is_empty() {
63+ return Some(format!("{} {} still running.", list(&self.pending), if self.pending.len() == 1 { "is" } else { "are" }));
64+ }
65+ None
66+ }
67+}
68+
69+pub(crate) fn list(names: &[String]) -> String {
70+ match names {
71+ [] => String::new(),
72+ [one] => one.clone(),
73+ [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
74+ }
75+}
76+
77+impl Work {
78+ pub(crate) async fn statuses(&self, repo_id: &str, sha: Option<&str>) -> Result<Vec<CommitStatus>> {
79+ let Some(sha) = sha else { return Ok(Vec::new()) };
80+ Ok(self
81+ .db
82+ .prepare("SELECT context, state, description, target_url, updated_at FROM commit_statuses WHERE repo_id = ? AND sha = ? ORDER BY context")
83+ .bind(&[repo_id.into(), sha.into()])?
84+ .all()
85+ .await?
86+ .results::<StatusRow>()?
87+ .into_iter()
88+ .map(CommitStatus::from)
89+ .collect())
90+ }
91+
92+ pub(crate) async fn set_commit_status(&self, a: SetCommitStatusArgs) -> Result<Outcome<bool>> {
93+ if !matches!(a.state.as_str(), "pending" | "success" | "failure" | "error") {
94+ return Ok(Outcome::fail(FailureCode::Invalid, "`state` is pending, success, failure or error."));
95+ }
96+ self.db
97+ .prepare(
98+ "INSERT INTO commit_statuses (repo_id, sha, context, state, description, target_url, updated_at)
99+ VALUES (?, ?, ?, ?, ?, ?, ?)
100+ ON CONFLICT (repo_id, sha, context) DO UPDATE SET
101+ state = excluded.state, description = excluded.description,
102+ target_url = excluded.target_url, updated_at = excluded.updated_at",
103+ )
104+ .bind(&[
105+ a.repo_id.as_str().into(),
106+ a.sha.as_str().into(),
107+ a.context.as_str().into(),
108+ a.state.as_str().into(),
109+ a.description.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
110+ a.target_url.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
111+ rfc3339(now_ms()).into(),
112+ ])?
113+ .run()
114+ .await?;
115+ if a.state == "pending" {
116+ return Ok(Outcome::Ok(true));
117+ }
118+ // Once every workflow on a pull request's head has finished, its
119+ // lifecycle moves on, as it does when its checks finish.
120+ let facts = WorkflowFacts::of(&self.statuses(&a.repo_id, Some(&a.sha)).await?);
121+ if !facts.pending.is_empty() {
122+ return Ok(Outcome::Ok(true));
123+ }
124+ let heads = self
125+ .db
126+ .prepare("SELECT id, number FROM pulls WHERE repo_id = ? AND head_commit = ? AND status IN ('draft', 'open')")
127+ .bind(&[a.repo_id.as_str().into(), a.sha.as_str().into()])?
128+ .all()
129+ .await?
130+ .results::<HeadRow>()?;
131+ for head in heads {
132+ self.publish_as(
133+ "checks.completed",
134+ &a.repo_id,
135+ None,
136+ ChecksEvent {
137+ pull_id: head.id,
138+ repo_id: a.repo_id.clone(),
139+ number: head.number,
140+ status: if facts.failed.is_empty() { "passed" } else { "failed" },
141+ commit: a.sha.clone(),
142+ },
143+ )
144+ .await?;
145+ }
146+ Ok(Outcome::Ok(true))
147+ }
148+}
149+
150+#[cfg(test)]
151+mod tests {
152+ use super::*;
153+
154+ fn status(context: &str, state: &str) -> CommitStatus {
155+ CommitStatus {
156+ context: context.into(),
157+ state: state.into(),
158+ description: None,
159+ target_url: None,
160+ updated_at: String::new(),
161+ }
162+ }
163+
164+ #[test]
165+ fn failures_come_before_waiting() {
166+ let facts = WorkflowFacts::of(&[status("CI / push", "pending"), status("Lint / pull_request", "failure"), status("Docs", "success")]);
167+ assert_eq!(facts.pending, ["CI / push"]);
168+ assert_eq!(facts.failed, ["Lint / pull_request"]);
169+ assert_eq!(facts.refusal().unwrap(), "Lint / pull_request failed.");
170+ let waiting = WorkflowFacts::of(&[status("A", "pending"), status("B", "pending")]);
171+ assert_eq!(waiting.refusal().unwrap(), "A and B are still running.");
172+ assert!(WorkflowFacts::of(&[status("A", "success")]).refusal().is_none());
173+ }
174+}