Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

2 commits

50 files+8833−2130/50 viewed
+27−0
871871 version = "0.1.0"
872872
873873 [[package]]
874+name = "g1t-actions"
875+version = "0.1.0"
876+dependencies = [
877+ "serde",
878+ "serde_json",
879+ "serde_yaml",
880+]
881+
882+[[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]]
874896 name = "g1t-api"
875897 version = "0.1.0"
876898 dependencies = [
887909 name = "g1t-automations"
888910 version = "0.1.0"
889911 dependencies = [
912+ "g1t-actions",
890913 "g1t-contracts",
891914 "g1t-kit",
892915 "serde",
9901013 dependencies = [
9911014 "anyhow",
9921015 "base64 0.22.1",
1016+ "g1t-actions",
1017+ "hex",
9931018 "serde",
9941019 "serde_json",
1020+ "serde_yaml",
1021+ "sha2 0.10.9",
9951022 "ureq",
9961023 ]
9971024
+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 }
+11−0
1+[package]
2+name = "g1t-actions"
3+version = "0.1.0"
4+edition.workspace = true
5+license.workspace = true
6+description = "GitHub Actions workflows on g1t: reading them, their expressions, their filters and their jobs. Shared by the actions service and the sandbox."
7+
8+[dependencies]
9+serde = { workspace = true }
10+serde_json = { workspace = true, features = ["preserve_order"] }
11+serde_yaml = "0.9"
+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+}
+221−0
1+//! g1t's events as GitHub's: which event and activity type each one is,
2+//! and the `github` context and `GITHUB_*` variables a run sees.
3+
4+use serde::{Deserialize, Serialize};
5+use serde_json::{Map, Value, json};
6+
7+/// The GitHub event and activity type for a g1t event, if it has one.
8+/// A g1t event can be more than one GitHub event: a pull request opening
9+/// is `pull_request` and `pull_request_target`.
10+pub fn github_events(kind: &str) -> Vec<(&'static str, Option<&'static str>)> {
11+ let pull = |action| vec![("pull_request", Some(action)), ("pull_request_target", Some(action))];
12+ match kind {
13+ "git.push" => vec![("push", None)],
14+ "pull.opened" => pull("opened"),
15+ "pull.updated" => pull("synchronize"),
16+ "pull.ready" => pull("ready_for_review"),
17+ "pull.closed" | "pull.merged" => pull("closed"),
18+ "issue.opened" => vec![("issues", Some("opened"))],
19+ "issue.updated" => vec![("issues", Some("edited"))],
20+ "issue.closed" => vec![("issues", Some("closed"))],
21+ "issue.reopened" => vec![("issues", Some("reopened"))],
22+ "issue.assigned" => vec![("issues", Some("assigned"))],
23+ "comment.created" => vec![("issue_comment", Some("created"))],
24+ "review.completed" => vec![("pull_request_review", Some("submitted"))],
25+ _ => Vec::new(),
26+ }
27+}
28+
29+/// What a run is about: enough to fill the `github` context.
30+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
31+#[serde(rename_all = "camelCase")]
32+pub struct RunInfo {
33+ /// `acme/web`.
34+ pub repository: String,
35+ pub repository_id: String,
36+ pub default_branch: String,
37+ pub event_name: String,
38+ /// The webhook-shaped payload, `github.event`.
39+ pub event: Value,
40+ /// `refs/heads/main`, `refs/tags/v1`, `refs/pull/3/merge`.
41+ pub git_ref: String,
42+ pub sha: String,
43+ /// For pull requests: the head and base branches.
44+ pub head_ref: Option<String>,
45+ pub base_ref: Option<String>,
46+ pub actor: String,
47+ pub actor_id: String,
48+ pub triggering_actor: String,
49+ pub run_id: String,
50+ pub run_number: u64,
51+ pub run_attempt: u64,
52+ /// The workflow's name.
53+ pub workflow: String,
54+ /// `.github/workflows/ci.yml`.
55+ pub workflow_path: String,
56+ pub server_url: String,
57+ pub api_url: String,
58+}
59+
60+impl RunInfo {
61+ pub fn ref_name(&self) -> String {
62+ self.git_ref
63+ .strip_prefix("refs/heads/")
64+ .or_else(|| self.git_ref.strip_prefix("refs/tags/"))
65+ .or_else(|| self.git_ref.strip_prefix("refs/"))
66+ .unwrap_or(&self.git_ref)
67+ .to_owned()
68+ }
69+
70+ pub fn ref_type(&self) -> &'static str {
71+ if self.git_ref.starts_with("refs/tags/") { "tag" } else { "branch" }
72+ }
73+
74+ fn owner(&self) -> &str {
75+ self.repository.split('/').next().unwrap_or_default()
76+ }
77+
78+ /// The `github` context for a job. `token` is `github.token` (and
79+ /// `secrets.GITHUB_TOKEN`); `job` is the job's id.
80+ pub fn context(&self, job: &str, token: &str, action: Option<&str>) -> Value {
81+ json!({
82+ "action": action.unwrap_or_default(),
83+ "action_path": "",
84+ "action_ref": "",
85+ "action_repository": "",
86+ "actor": self.actor,
87+ "actor_id": self.actor_id,
88+ "api_url": self.api_url,
89+ "base_ref": self.base_ref.clone().unwrap_or_default(),
90+ "env": "",
91+ "event": self.event,
92+ "event_name": self.event_name,
93+ "event_path": "/home/runner/_temp/event.json",
94+ "graphql_url": "",
95+ "head_ref": self.head_ref.clone().unwrap_or_default(),
96+ "job": job,
97+ "path": "",
98+ "ref": self.git_ref,
99+ "ref_name": self.ref_name(),
100+ "ref_protected": self.ref_name() == self.default_branch,
101+ "ref_type": self.ref_type(),
102+ "repository": self.repository,
103+ "repository_id": self.repository_id,
104+ "repository_owner": self.owner(),
105+ "repository_owner_id": "",
106+ "repositoryUrl": format!("{}/{}.git", self.server_url, self.repository),
107+ "retention_days": 14,
108+ "run_attempt": self.run_attempt.to_string(),
109+ "run_id": self.run_id,
110+ "run_number": self.run_number.to_string(),
111+ "secret_source": "Actions",
112+ "server_url": self.server_url,
113+ "sha": self.sha,
114+ "token": token,
115+ "triggering_actor": self.triggering_actor,
116+ "workflow": self.workflow,
117+ "workflow_ref": format!("{}/{}@{}", self.repository, self.workflow_path, self.git_ref),
118+ "workflow_sha": self.sha,
119+ "workspace": WORKSPACE,
120+ })
121+ }
122+
123+ /// The `GITHUB_*` and `RUNNER_*` variables every step gets.
124+ pub fn variables(&self, job: &str) -> Map<String, Value> {
125+ let mut vars = Map::new();
126+ let mut set = |key: &str, value: String| {
127+ vars.insert(key.to_owned(), Value::String(value));
128+ };
129+ set("CI", "true".into());
130+ set("GITHUB_ACTIONS", "true".into());
131+ set("G1T", "true".into());
132+ set("GITHUB_ACTOR", self.actor.clone());
133+ set("GITHUB_ACTOR_ID", self.actor_id.clone());
134+ set("GITHUB_API_URL", self.api_url.clone());
135+ set("GITHUB_BASE_REF", self.base_ref.clone().unwrap_or_default());
136+ set("GITHUB_EVENT_NAME", self.event_name.clone());
137+ set("GITHUB_EVENT_PATH", "/home/runner/_temp/event.json".into());
138+ set("GITHUB_GRAPHQL_URL", String::new());
139+ set("GITHUB_HEAD_REF", self.head_ref.clone().unwrap_or_default());
140+ set("GITHUB_JOB", job.to_owned());
141+ set("GITHUB_REF", self.git_ref.clone());
142+ set("GITHUB_REF_NAME", self.ref_name());
143+ set("GITHUB_REF_PROTECTED", (self.ref_name() == self.default_branch).to_string());
144+ set("GITHUB_REF_TYPE", self.ref_type().into());
145+ set("GITHUB_REPOSITORY", self.repository.clone());
146+ set("GITHUB_REPOSITORY_ID", self.repository_id.clone());
147+ set("GITHUB_REPOSITORY_OWNER", self.owner().to_owned());
148+ set("GITHUB_RETENTION_DAYS", "14".into());
149+ set("GITHUB_RUN_ATTEMPT", self.run_attempt.to_string());
150+ set("GITHUB_RUN_ID", self.run_id.clone());
151+ set("GITHUB_RUN_NUMBER", self.run_number.to_string());
152+ set("GITHUB_SERVER_URL", self.server_url.clone());
153+ set("GITHUB_SHA", self.sha.clone());
154+ set("GITHUB_TRIGGERING_ACTOR", self.triggering_actor.clone());
155+ set("GITHUB_WORKFLOW", self.workflow.clone());
156+ set("GITHUB_WORKFLOW_REF", format!("{}/{}@{}", self.repository, self.workflow_path, self.git_ref));
157+ set("GITHUB_WORKFLOW_SHA", self.sha.clone());
158+ set("GITHUB_WORKSPACE", WORKSPACE.into());
159+ set("RUNNER_ARCH", "X64".into());
160+ set("RUNNER_NAME", "g1t".into());
161+ set("RUNNER_OS", "Linux".into());
162+ set("RUNNER_TEMP", "/home/runner/_temp".into());
163+ set("RUNNER_TOOL_CACHE", "/home/runner/_tool".into());
164+ set("RUNNER_ENVIRONMENT", "github-hosted".into());
165+ vars
166+ }
167+}
168+
169+/// Where a job's repository is checked out, as on GitHub's runners.
170+pub const WORKSPACE: &str = "/home/runner/work/repo";
171+
172+/// The `runner` context.
173+pub fn runner_context() -> Value {
174+ json!({
175+ "name": "g1t",
176+ "os": "Linux",
177+ "arch": "X64",
178+ "temp": "/home/runner/_temp",
179+ "tool_cache": "/home/runner/_tool",
180+ "environment": "github-hosted",
181+ "debug": "",
182+ })
183+}
184+
185+#[cfg(test)]
186+mod tests {
187+ use super::*;
188+
189+ #[test]
190+ fn g1t_events_are_github_events() {
191+ assert_eq!(github_events("git.push"), [("push", None)]);
192+ assert_eq!(github_events("pull.updated")[0], ("pull_request", Some("synchronize")));
193+ assert_eq!(github_events("pull.merged")[1], ("pull_request_target", Some("closed")));
194+ assert_eq!(github_events("comment.created"), [("issue_comment", Some("created"))]);
195+ assert!(github_events("session.appended").is_empty());
196+ }
197+
198+ #[test]
199+ fn contexts_and_variables_agree() {
200+ let info = RunInfo {
201+ repository: "acme/web".into(),
202+ default_branch: "main".into(),
203+ event_name: "push".into(),
204+ git_ref: "refs/tags/v1.2.0".into(),
205+ sha: "abc".into(),
206+ run_number: 7,
207+ run_attempt: 1,
208+ server_url: "https://g1t.sh".into(),
209+ ..RunInfo::default()
210+ };
211+ let github = info.context("build", "tok", None);
212+ assert_eq!(github["ref_name"], "v1.2.0");
213+ assert_eq!(github["ref_type"], "tag");
214+ assert_eq!(github["repository_owner"], "acme");
215+ assert_eq!(github["run_number"], "7");
216+ let vars = info.variables("build");
217+ assert_eq!(vars["GITHUB_REF_NAME"], "v1.2.0");
218+ assert_eq!(vars["GITHUB_JOB"], "build");
219+ assert_eq!(vars["RUNNER_OS"], "Linux");
220+ }
221+}
+1756−0
1+//! The GitHub Actions expression language: the `${{ }}` language.
2+//!
3+//! This follows GitHub's "Evaluate expressions in workflows and actions"
4+//! precisely, so a real `.github/workflows/*.yml` evaluates here the way it
5+//! does on GitHub: the same literals, the same operator precedence, the same
6+//! loose equality (with its coercions to number), the same case-insensitive
7+//! string handling, the same object filters (`labels.*.name`) and the same
8+//! functions. Parse errors are found before anything is evaluated, so an
9+//! unknown context or function is an error even in a branch that would never
10+//! run, as on GitHub.
11+
12+use serde_json::{Map, Value};
13+use std::borrow::Cow;
14+use std::cmp::Ordering;
15+
16+/// How the job is going, for success(), failure(), cancelled(), always().
17+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18+pub enum Status {
19+ Success,
20+ Failure,
21+ Cancelled,
22+}
23+
24+pub struct Scope<'a> {
25+ /// Top-level contexts by lower-case name: github, env, vars, secrets, inputs, matrix, strategy, needs, steps, job, jobs, runner.
26+ pub contexts: &'a Map<String, Value>,
27+ pub status: Status,
28+ /// hashFiles(...) when the caller can compute it (the sandbox); None means hashFiles evaluates to "".
29+ #[allow(clippy::type_complexity)]
30+ pub hash_files: Option<&'a dyn Fn(&[String]) -> String>,
31+}
32+
33+/// The contexts a workflow may name, whether or not the caller supplied them.
34+const NAMED_VALUES: &[&str] = &[
35+ "github", "env", "vars", "secrets", "inputs", "matrix", "strategy", "needs", "steps", "job",
36+ "jobs", "runner",
37+];
38+
39+/// Evaluates one expression (the text between `${{` and `}}`, or a bare `if:`).
40+pub fn evaluate(expression: &str, scope: &Scope) -> Result<Value, String> {
41+ let ast = parse(expression, scope.contexts)?;
42+ Ok(eval(&ast, scope)?.into_value())
43+}
44+
45+/// An `if:` value: with or without `${{ }}` around it; when the expression calls none of success/failure/cancelled/always, it is implicitly `success() && (expr)`. An empty condition is `success()`.
46+pub fn condition(text: &str, scope: &Scope) -> Result<bool, String> {
47+ let success = scope.status == Status::Success;
48+ let trimmed = text.trim();
49+ let source = match single_expression(trimmed) {
50+ Some(inner) => inner,
51+ // Text around or between expressions makes the whole thing a string,
52+ // as on GitHub: it is true whenever it interpolates to anything.
53+ None if has_expression(trimmed) => {
54+ let text = interpolate(trimmed, scope)?;
55+ return Ok(success && !text.is_empty());
56+ }
57+ None => trimmed,
58+ };
59+ if source.trim().is_empty() {
60+ return Ok(success);
61+ }
62+ let ast = parse(source, scope.contexts)?;
63+ if uses_status(&ast) {
64+ Ok(eval(&ast, scope)?.truthy())
65+ } else {
66+ Ok(success && eval(&ast, scope)?.truthy())
67+ }
68+}
69+
70+/// Replaces each `${{ expr }}` in text with the value converted to a string. Text without `${{` is returned unchanged.
71+pub fn interpolate(text: &str, scope: &Scope) -> Result<String, String> {
72+ if !has_expression(text) {
73+ return Ok(text.to_string());
74+ }
75+ let mut out = String::with_capacity(text.len());
76+ let mut from = 0;
77+ while let Some(rel) = text[from..].find("${{") {
78+ let open = from + rel;
79+ out.push_str(&text[from..open]);
80+ let body = open + 3;
81+ let close = find_close(text, body).ok_or_else(|| {
82+ format!(
83+ "The expression is not closed. An unescaped ${{{{ sequence was found, but the closing }}}} sequence was not found: {text}"
84+ )
85+ })?;
86+ let value = evaluate(&text[body..close], scope)?;
87+ out.push_str(&to_text(&value));
88+ from = close + 2;
89+ }
90+ out.push_str(&text[from..]);
91+ Ok(out)
92+}
93+
94+/// Interpolates every string inside a JSON value (keys too). If a string is exactly one `${{ expr }}` and nothing else, the result keeps the expression's type (as GitHub does for e.g. `strategy.matrix: ${{ fromJSON(...) }}`, `continue-on-error: ${{ ... }}`).
95+pub fn interpolate_value(value: &Value, scope: &Scope) -> Result<Value, String> {
96+ Ok(match value {
97+ Value::String(text) => match single_expression(text) {
98+ Some(inner) => evaluate(inner, scope)?,
99+ None => Value::String(interpolate(text, scope)?),
100+ },
101+ Value::Array(items) => Value::Array(
102+ items
103+ .iter()
104+ .map(|item| interpolate_value(item, scope))
105+ .collect::<Result<_, _>>()?,
106+ ),
107+ Value::Object(map) => {
108+ let mut out = Map::new();
109+ for (key, item) in map {
110+ out.insert(interpolate(key, scope)?, interpolate_value(item, scope)?);
111+ }
112+ Value::Object(out)
113+ }
114+ other => other.clone(),
115+ })
116+}
117+
118+/// false, 0, -0, NaN, "" and null are falsy; everything else is truthy.
119+pub fn truthy(value: &Value) -> bool {
120+ match value {
121+ Value::Null => false,
122+ Value::Bool(b) => *b,
123+ Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()),
124+ Value::String(s) => !s.is_empty(),
125+ Value::Array(_) | Value::Object(_) => true,
126+ }
127+}
128+
129+/// A value as GitHub writes it into a string: null → "", bools "true"/"false", numbers as GitHub formats them (integers without ".0"), strings as-is, and `Array` or `Object` for collections, as GitHub's runner does (`toJSON` gives their contents).
130+pub fn to_text(value: &Value) -> String {
131+ match value {
132+ Value::Null => String::new(),
133+ Value::Bool(b) => b.to_string(),
134+ Value::Number(n) => format_number(n.as_f64().unwrap_or(f64::NAN)),
135+ Value::String(s) => s.clone(),
136+ Value::Array(_) => "Array".to_owned(),
137+ Value::Object(_) => "Object".to_owned(),
138+ }
139+}
140+
141+/// Whether text contains `${{`.
142+pub fn has_expression(text: &str) -> bool {
143+ text.contains("${{")
144+}
145+
146+// ---------------------------------------------------------------------------
147+// Template scanning
148+
149+/// Finds the `}}` closing an expression whose body starts at byte `from`,
150+/// skipping over string literals (which may themselves contain `}}`).
151+fn find_close(text: &str, from: usize) -> Option<usize> {
152+ let bytes = text.as_bytes();
153+ let mut in_string = false;
154+ let mut i = from;
155+ while i < bytes.len() {
156+ match bytes[i] {
157+ b'\'' => in_string = !in_string,
158+ b'}' if !in_string && bytes.get(i + 1) == Some(&b'}') => return Some(i),
159+ _ => {}
160+ }
161+ i += 1;
162+ }
163+ None
164+}
165+
166+/// The body of text when text is exactly one `${{ expr }}` and nothing else.
167+fn single_expression(text: &str) -> Option<&str> {
168+ let text = text.trim();
169+ if !text.starts_with("${{") {
170+ return None;
171+ }
172+ let close = find_close(text, 3)?;
173+ (close + 2 == text.len()).then(|| &text[3..close])
174+}
175+
176+// ---------------------------------------------------------------------------
177+// Lexing
178+
179+#[derive(Clone, Debug, PartialEq)]
180+enum Tok {
181+ Null,
182+ True,
183+ False,
184+ Number(f64),
185+ Str(String),
186+ Ident(String),
187+ Dot,
188+ Star,
189+ LBracket,
190+ RBracket,
191+ LParen,
192+ RParen,
193+ Comma,
194+ Not,
195+ Lt,
196+ Le,
197+ Gt,
198+ Ge,
199+ Eq,
200+ Ne,
201+ And,
202+ Or,
203+}
204+
205+#[derive(Clone, Debug)]
206+struct Token {
207+ tok: Tok,
208+ /// 1-based character position within the expression.
209+ pos: usize,
210+ /// The token as written, for error messages.
211+ text: String,
212+}
213+
214+fn located(message: &str, pos: usize, src: &str) -> String {
215+ format!("{message}. Located at position {pos} within expression: {src}")
216+}
217+
218+fn lex(src: &str) -> Result<Vec<Token>, String> {
219+ let chars: Vec<char> = src.chars().collect();
220+ let mut out: Vec<Token> = Vec::new();
221+ let mut i = 0;
222+ while i < chars.len() {
223+ let c = chars[i];
224+ if c.is_whitespace() {
225+ i += 1;
226+ continue;
227+ }
228+ let start = i;
229+ let next = chars.get(i + 1).copied();
230+ // Whether a value (rather than an operator) may come next, which
231+ // decides whether `.5` is a number or a dereference.
232+ let value_may_start = out.last().is_none_or(|t| {
233+ !matches!(
234+ t.tok,
235+ Tok::Ident(_)
236+ | Tok::Number(_)
237+ | Tok::Str(_)
238+ | Tok::Null
239+ | Tok::True
240+ | Tok::False
241+ | Tok::RParen
242+ | Tok::RBracket
243+ | Tok::Star
244+ )
245+ });
246+ let starts_number = c.is_ascii_digit()
247+ || ((c == '-' || c == '+') && next.is_some_and(|n| n.is_ascii_digit() || n == '.'))
248+ || (c == '.' && value_may_start && next.is_some_and(|n| n.is_ascii_digit()));
249+ let tok = if starts_number {
250+ i += 1;
251+ while i < chars.len() {
252+ let d = chars[i];
253+ let so_far: String = chars[start..i].iter().collect();
254+ let hex = so_far
255+ .trim_start_matches(['-', '+'])
256+ .to_ascii_lowercase()
257+ .starts_with("0x");
258+ let exponent_sign =
259+ (d == '+' || d == '-') && matches!(chars[i - 1], 'e' | 'E') && !hex;
260+ if d.is_ascii_alphanumeric() || d == '.' || d == '_' || exponent_sign {
261+ i += 1;
262+ } else {
263+ break;
264+ }
265+ }
266+ let text: String = chars[start..i].iter().collect();
267+ match parse_number(&text, false) {
268+ Some(n) => Tok::Number(n),
269+ None => {
270+ return Err(located(
271+ &format!("Unexpected symbol: '{text}'"),
272+ start + 1,
273+ src,
274+ ));
275+ }
276+ }
277+ } else if c.is_alphabetic() || c == '_' {
278+ i += 1;
279+ while i < chars.len()
280+ && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '-')
281+ {
282+ i += 1;
283+ }
284+ let word: String = chars[start..i].iter().collect();
285+ match word.as_str() {
286+ "null" => Tok::Null,
287+ "true" => Tok::True,
288+ "false" => Tok::False,
289+ _ => Tok::Ident(word),
290+ }
291+ } else if c == '\'' {
292+ i += 1;
293+ let mut s = String::new();
294+ loop {
295+ match chars.get(i) {
296+ None => {
297+ let text: String = chars[start..].iter().collect();
298+ return Err(located(
299+ &format!("Unexpected symbol: '{text}'"),
300+ start + 1,
301+ src,
302+ ));
303+ }
304+ Some('\'') if chars.get(i + 1) == Some(&'\'') => {
305+ s.push('\'');
306+ i += 2;
307+ }
308+ Some('\'') => {
309+ i += 1;
310+ break;
311+ }
312+ Some(&ch) => {
313+ s.push(ch);
314+ i += 1;
315+ }
316+ }
317+ }
318+ Tok::Str(s)
319+ } else {
320+ let two = |a: char, b: char| c == a && next == Some(b);
321+ let (tok, len) = if two('=', '=') {
322+ (Tok::Eq, 2)
323+ } else if two('!', '=') {
324+ (Tok::Ne, 2)
325+ } else if two('<', '=') {
326+ (Tok::Le, 2)
327+ } else if two('>', '=') {
328+ (Tok::Ge, 2)
329+ } else if two('&', '&') {
330+ (Tok::And, 2)
331+ } else if two('|', '|') {
332+ (Tok::Or, 2)
333+ } else {
334+ let tok = match c {
335+ '.' => Tok::Dot,
336+ '*' => Tok::Star,
337+ '[' => Tok::LBracket,
338+ ']' => Tok::RBracket,
339+ '(' => Tok::LParen,
340+ ')' => Tok::RParen,
341+ ',' => Tok::Comma,
342+ '!' => Tok::Not,
343+ '<' => Tok::Lt,
344+ '>' => Tok::Gt,
345+ _ => {
346+ return Err(located(
347+ &format!("Unexpected symbol: '{c}'"),
348+ start + 1,
349+ src,
350+ ));
351+ }
352+ };
353+ (tok, 1)
354+ };
355+ i += len;
356+ tok
357+ };
358+ out.push(Token {
359+ tok,
360+ pos: start + 1,
361+ text: chars[start..i].iter().collect(),
362+ });
363+ }
364+ Ok(out)
365+}
366+
367+/// Parses a number. Literals (`lenient == false`) must be exactly a number;
368+/// strings being coerced (`lenient == true`) may be padded with whitespace,
369+/// and the empty string is 0.
370+fn parse_number(text: &str, lenient: bool) -> Option<f64> {
371+ let s = if lenient { text.trim() } else { text };
372+ if s.is_empty() {
373+ return lenient.then_some(0.0);
374+ }
375+ let (negative, body) = match s.as_bytes()[0] {
376+ b'-' => (true, &s[1..]),
377+ b'+' => (false, &s[1..]),
378+ _ => (false, s),
379+ };
380+ let sign = if negative { -1.0 } else { 1.0 };
381+ let lower = body.to_ascii_lowercase();
382+ if let Some(hex) = lower.strip_prefix("0x") {
383+ return u64::from_str_radix(hex, 16).ok().map(|n| sign * n as f64);
384+ }
385+ if let Some(oct) = lower.strip_prefix("0o") {
386+ return u64::from_str_radix(oct, 8).ok().map(|n| sign * n as f64);
387+ }
388+ if lenient && body == "Infinity" {
389+ return Some(sign * f64::INFINITY);
390+ }
391+ // digits [. digits] [e [+-] digits], with at least one mantissa digit.
392+ let bytes = body.as_bytes();
393+ let mut i = 0;
394+ let mut mantissa_digits = 0;
395+ while i < bytes.len() && bytes[i].is_ascii_digit() {
396+ i += 1;
397+ mantissa_digits += 1;
398+ }
399+ if i < bytes.len() && bytes[i] == b'.' {
400+ i += 1;
401+ while i < bytes.len() && bytes[i].is_ascii_digit() {
402+ i += 1;
403+ mantissa_digits += 1;
404+ }
405+ }
406+ if mantissa_digits == 0 {
407+ return None;
408+ }
409+ if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
410+ i += 1;
411+ if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
412+ i += 1;
413+ }
414+ let digits_start = i;
415+ while i < bytes.len() && bytes[i].is_ascii_digit() {
416+ i += 1;
417+ }
418+ if i == digits_start {
419+ return None;
420+ }
421+ }
422+ if i != bytes.len() {
423+ return None;
424+ }
425+ let normalized = if body.starts_with('.') {
426+ format!("0{body}")
427+ } else {
428+ body.to_string()
429+ };
430+ normalized.parse::<f64>().ok().map(|n| sign * n)
431+}
432+
433+// ---------------------------------------------------------------------------
434+// Parsing
435+
436+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
437+enum Func {
438+ Contains,
439+ StartsWith,
440+ EndsWith,
441+ Format,
442+ Join,
443+ ToJson,
444+ FromJson,
445+ HashFiles,
446+ Success,
447+ Always,
448+ Cancelled,
449+ Failure,
450+}
451+
452+/// Name, function, fewest and most arguments.
453+const FUNCTIONS: &[(&str, Func, usize, usize)] = &[
454+ ("contains", Func::Contains, 2, 2),
455+ ("startsWith", Func::StartsWith, 2, 2),
456+ ("endsWith", Func::EndsWith, 2, 2),
457+ ("format", Func::Format, 1, usize::MAX),
458+ ("join", Func::Join, 1, 2),
459+ ("toJSON", Func::ToJson, 1, 1),
460+ ("fromJSON", Func::FromJson, 1, 1),
461+ ("hashFiles", Func::HashFiles, 1, usize::MAX),
462+ ("success", Func::Success, 0, 0),
463+ ("always", Func::Always, 0, 0),
464+ ("cancelled", Func::Cancelled, 0, 0),
465+ ("failure", Func::Failure, 0, 0),
466+];
467+
468+#[derive(Clone, Copy, Debug)]
469+enum CmpOp {
470+ Lt,
471+ Le,
472+ Gt,
473+ Ge,
474+ Eq,
475+ Ne,
476+}
477+
478+#[derive(Debug)]
479+enum Expr {
480+ Literal(Value),
481+ Named(String),
482+ Property(Box<Expr>, String),
483+ Index(Box<Expr>, Box<Expr>),
484+ Wildcard(Box<Expr>),
485+ Not(Box<Expr>),
486+ Compare(CmpOp, Box<Expr>, Box<Expr>),
487+ And(Box<Expr>, Box<Expr>),
488+ Or(Box<Expr>, Box<Expr>),
489+ Call(Func, Vec<Expr>),
490+}
491+
492+fn parse(src: &str, contexts: &Map<String, Value>) -> Result<Expr, String> {
493+ let toks = lex(src)?;
494+ if toks.is_empty() {
495+ return Err(format!("An expression was expected: '{src}'"));
496+ }
497+ let mut parser = Parser {
498+ toks,
499+ i: 0,
500+ src,
501+ contexts,
502+ };
503+ let expr = parser.or()?;
504+ if parser.i < parser.toks.len() {
505+ return Err(parser.unexpected());
506+ }
507+ Ok(expr)
508+}
509+
510+struct Parser<'s> {
511+ toks: Vec<Token>,
512+ i: usize,
513+ src: &'s str,
514+ contexts: &'s Map<String, Value>,
515+}
516+
517+impl Parser<'_> {
518+ fn peek(&self) -> Option<&Tok> {
519+ self.toks.get(self.i).map(|t| &t.tok)
520+ }
521+
522+ fn eat(&mut self, tok: &Tok) -> bool {
523+ if self.peek() == Some(tok) {
524+ self.i += 1;
525+ true
526+ } else {
527+ false
528+ }
529+ }
530+
531+ fn unexpected(&self) -> String {
532+ match self.toks.get(self.i) {
533+ Some(t) => located(&format!("Unexpected symbol: '{}'", t.text), t.pos, self.src),
534+ None => match self.toks.last() {
535+ Some(t) => located(
536+ &format!("Unexpected end of expression: '{}'", t.text),
537+ t.pos,
538+ self.src,
539+ ),
540+ None => format!("An expression was expected: '{}'", self.src),
541+ },
542+ }
543+ }
544+
545+ fn or(&mut self) -> Result<Expr, String> {
546+ let mut left = self.and()?;
547+ while self.eat(&Tok::Or) {
548+ let right = self.and()?;
549+ left = Expr::Or(Box::new(left), Box::new(right));
550+ }
551+ Ok(left)
552+ }
553+
554+ fn and(&mut self) -> Result<Expr, String> {
555+ let mut left = self.equality()?;
556+ while self.eat(&Tok::And) {
557+ let right = self.equality()?;
558+ left = Expr::And(Box::new(left), Box::new(right));
559+ }
560+ Ok(left)
561+ }
562+
563+ fn equality(&mut self) -> Result<Expr, String> {
564+ let mut left = self.comparison()?;
565+ loop {
566+ let op = match self.peek() {
567+ Some(Tok::Eq) => CmpOp::Eq,
568+ Some(Tok::Ne) => CmpOp::Ne,
569+ _ => return Ok(left),
570+ };
571+ self.i += 1;
572+ let right = self.comparison()?;
573+ left = Expr::Compare(op, Box::new(left), Box::new(right));
574+ }
575+ }
576+
577+ fn comparison(&mut self) -> Result<Expr, String> {
578+ let mut left = self.unary()?;
579+ loop {
580+ let op = match self.peek() {
581+ Some(Tok::Lt) => CmpOp::Lt,
582+ Some(Tok::Le) => CmpOp::Le,
583+ Some(Tok::Gt) => CmpOp::Gt,
584+ Some(Tok::Ge) => CmpOp::Ge,
585+ _ => return Ok(left),
586+ };
587+ self.i += 1;
588+ let right = self.unary()?;
589+ left = Expr::Compare(op, Box::new(left), Box::new(right));
590+ }
591+ }
592+
593+ fn unary(&mut self) -> Result<Expr, String> {
594+ if self.eat(&Tok::Not) {
595+ return Ok(Expr::Not(Box::new(self.unary()?)));
596+ }
597+ self.postfix()
598+ }
599+
600+ fn postfix(&mut self) -> Result<Expr, String> {
601+ let mut expr = self.primary()?;
602+ loop {
603+ if self.eat(&Tok::Dot) {
604+ let token = self.toks.get(self.i).cloned();
605+ expr = match token.map(|t| (t.tok, t.text)) {
606+ Some((Tok::Star, _)) => Expr::Wildcard(Box::new(expr)),
607+ Some((Tok::Ident(name), _)) => Expr::Property(Box::new(expr), name),
608+ Some((Tok::True | Tok::False | Tok::Null, text)) => {
609+ Expr::Property(Box::new(expr), text)
610+ }
611+ _ => return Err(self.unexpected()),
612+ };
613+ self.i += 1;
614+ } else if self.eat(&Tok::LBracket) {
615+ if self.peek() == Some(&Tok::Star)
616+ && self.toks.get(self.i + 1).map(|t| &t.tok) == Some(&Tok::RBracket)
617+ {
618+ self.i += 2;
619+ expr = Expr::Wildcard(Box::new(expr));
620+ } else {
621+ let index = self.or()?;
622+ if !self.eat(&Tok::RBracket) {
623+ return Err(self.unexpected());
624+ }
625+ expr = Expr::Index(Box::new(expr), Box::new(index));
626+ }
627+ } else {
628+ return Ok(expr);
629+ }
630+ }
631+ }
632+
633+ fn primary(&mut self) -> Result<Expr, String> {
634+ let Some(token) = self.toks.get(self.i).cloned() else {
635+ return Err(self.unexpected());
636+ };
637+ self.i += 1;
638+ match token.tok {
639+ Tok::Null => Ok(Expr::Literal(Value::Null)),
640+ Tok::True => Ok(Expr::Literal(Value::Bool(true))),
641+ Tok::False => Ok(Expr::Literal(Value::Bool(false))),
642+ Tok::Number(n) => Ok(Expr::Literal(number(n))),
643+ Tok::Str(s) => Ok(Expr::Literal(Value::String(s))),
644+ Tok::LParen => {
645+ let inner = self.or()?;
646+ if !self.eat(&Tok::RParen) {
647+ return Err(self.unexpected());
648+ }
649+ Ok(inner)
650+ }
651+ Tok::Ident(name) if self.peek() == Some(&Tok::LParen) => {
652+ self.i += 1;
653+ self.call(&name, token.pos)
654+ }
655+ Tok::Ident(name) => {
656+ let known = NAMED_VALUES.iter().any(|n| n.eq_ignore_ascii_case(&name))
657+ || self.contexts.keys().any(|k| k.eq_ignore_ascii_case(&name));
658+ if !known {
659+ return Err(located(
660+ &format!("Unrecognized named-value: '{name}'"),
661+ token.pos,
662+ self.src,
663+ ));
664+ }
665+ Ok(Expr::Named(name))
666+ }
667+ _ => {
668+ self.i -= 1;
669+ Err(self.unexpected())
670+ }
671+ }
672+ }
673+
674+ fn call(&mut self, name: &str, pos: usize) -> Result<Expr, String> {
675+ let Some(&(canonical, func, min, max)) = FUNCTIONS
676+ .iter()
677+ .find(|(n, ..)| n.eq_ignore_ascii_case(name))
678+ else {
679+ return Err(located(
680+ &format!("Unrecognized function: '{name}'"),
681+ pos,
682+ self.src,
683+ ));
684+ };
685+ let mut args = Vec::new();
686+ if !self.eat(&Tok::RParen) {
687+ loop {
688+ args.push(self.or()?);
689+ if self.eat(&Tok::Comma) {
690+ continue;
691+ }
692+ if self.eat(&Tok::RParen) {
693+ break;
694+ }
695+ return Err(self.unexpected());
696+ }
697+ }
698+ if args.len() < min {
699+ return Err(located(
700+ &format!("Too few parameters supplied: '{canonical}'"),
701+ pos,
702+ self.src,
703+ ));
704+ }
705+ if args.len() > max {
706+ return Err(located(
707+ &format!("Too many parameters supplied: '{canonical}'"),
708+ pos,
709+ self.src,
710+ ));
711+ }
712+ Ok(Expr::Call(func, args))
713+ }
714+}
715+
716+/// Whether the expression calls success(), failure(), cancelled() or always().
717+fn uses_status(expr: &Expr) -> bool {
718+ match expr {
719+ Expr::Literal(_) | Expr::Named(_) => false,
720+ Expr::Property(base, _) | Expr::Wildcard(base) | Expr::Not(base) => uses_status(base),
721+ Expr::Index(a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
722+ uses_status(a) || uses_status(b)
723+ }
724+ Expr::Call(func, args) => {
725+ matches!(
726+ func,
727+ Func::Success | Func::Failure | Func::Cancelled | Func::Always
728+ ) || args.iter().any(uses_status)
729+ }
730+ }
731+}
732+
733+// ---------------------------------------------------------------------------
734+// Evaluation
735+
736+/// A value while evaluating: borrowed from the contexts where possible, and
737+/// a filtered array (the result of a `*`) kept apart, since property access on
738+/// it applies to every item.
739+enum Ev<'c> {
740+ One(Cow<'c, Value>),
741+ Filtered(Vec<Cow<'c, Value>>),
742+}
743+
744+impl Ev<'_> {
745+ fn into_value(self) -> Value {
746+ match self {
747+ Ev::One(v) => v.into_owned(),
748+ Ev::Filtered(items) => Value::Array(items.into_iter().map(Cow::into_owned).collect()),
749+ }
750+ }
751+
752+ fn truthy(&self) -> bool {
753+ match self {
754+ Ev::One(v) => truthy(v),
755+ Ev::Filtered(_) => true,
756+ }
757+ }
758+}
759+
760+fn owned<'c>(value: Value) -> Ev<'c> {
761+ Ev::One(Cow::Owned(value))
762+}
763+
764+/// A key on an object: the exact key if present, otherwise ignoring ASCII case.
765+fn find_key<'m>(map: &'m Map<String, Value>, key: &str) -> Option<&'m String> {
766+ if let Some((k, _)) = map.get_key_value(key) {
767+ return Some(k);
768+ }
769+ map.keys().find(|k| k.eq_ignore_ascii_case(key))
770+}
771+
772+enum Key {
773+ Name(String),
774+ Index(usize),
775+}
776+
777+fn child<'c>(value: Cow<'c, Value>, key: &Key) -> Option<Cow<'c, Value>> {
778+ match (value, key) {
779+ (Cow::Borrowed(Value::Object(map)), Key::Name(name)) => find_key(map, name)
780+ .and_then(|k| map.get(k))
781+ .map(Cow::Borrowed),
782+ (Cow::Owned(Value::Object(mut map)), Key::Name(name)) => {
783+ let k = find_key(&map, name)?.clone();
784+ map.remove(&k).map(Cow::Owned)
785+ }
786+ (Cow::Borrowed(Value::Array(items)), Key::Index(i)) => items.get(*i).map(Cow::Borrowed),
787+ (Cow::Owned(Value::Array(items)), Key::Index(i)) => {
788+ items.into_iter().nth(*i).map(Cow::Owned)
789+ }
790+ _ => None,
791+ }
792+}
793+
794+fn children(value: Cow<'_, Value>) -> Vec<Cow<'_, Value>> {
795+ match value {
796+ Cow::Borrowed(Value::Array(items)) => items.iter().map(Cow::Borrowed).collect(),
797+ Cow::Borrowed(Value::Object(map)) => map.values().map(Cow::Borrowed).collect(),
798+ Cow::Owned(Value::Array(items)) => items.into_iter().map(Cow::Owned).collect(),
799+ Cow::Owned(Value::Object(map)) => map.into_iter().map(|(_, v)| Cow::Owned(v)).collect(),
800+ _ => Vec::new(),
801+ }
802+}
803+
804+/// The key `index` selects on `target`: a position on an array, a name on an object.
805+fn key_for(target: &Value, index: &Value) -> Option<Key> {
806+ match target {
807+ Value::Array(_) => {
808+ let n = to_number(index);
809+ (n.is_finite() && n >= 0.0).then(|| Key::Index(n.trunc() as usize))
810+ }
811+ Value::Object(_) => Some(Key::Name(to_text(index))),
812+ _ => None,
813+ }
814+}
815+
816+fn eval<'c>(expr: &Expr, scope: &Scope<'c>) -> Result<Ev<'c>, String> {
817+ Ok(match expr {
818+ Expr::Literal(v) => owned(v.clone()),
819+ Expr::Named(name) => {
820+ let contexts: &'c Map<String, Value> = scope.contexts;
821+ match find_key(contexts, name).and_then(|k| contexts.get(k)) {
822+ Some(v) => Ev::One(Cow::Borrowed(v)),
823+ None => owned(Value::Null),
824+ }
825+ }
826+ Expr::Property(base, name) => {
827+ let key = Key::Name(name.clone());
828+ match eval(base, scope)? {
829+ Ev::One(v) => Ev::One(child(v, &key).unwrap_or(Cow::Owned(Value::Null))),
830+ Ev::Filtered(items) => {
831+ Ev::Filtered(items.into_iter().filter_map(|it| child(it, &key)).collect())
832+ }
833+ }
834+ }
835+ Expr::Index(base, index) => {
836+ let base = eval(base, scope)?;
837+ let index = eval(index, scope)?.into_value();
838+ match base {
839+ Ev::One(v) => {
840+ let found = key_for(&v, &index).and_then(|key| child(v, &key));
841+ Ev::One(found.unwrap_or(Cow::Owned(Value::Null)))
842+ }
843+ Ev::Filtered(items) => Ev::Filtered(
844+ items
845+ .into_iter()
846+ .filter_map(|it| key_for(&it, &index).and_then(|key| child(it, &key)))
847+ .collect(),
848+ ),
849+ }
850+ }
851+ Expr::Wildcard(base) => match eval(base, scope)? {
852+ Ev::One(v) => Ev::Filtered(children(v)),
853+ Ev::Filtered(items) => Ev::Filtered(items.into_iter().flat_map(children).collect()),
854+ },
855+ Expr::Not(inner) => owned(Value::Bool(!eval(inner, scope)?.truthy())),
856+ Expr::And(a, b) => {
857+ let left = eval(a, scope)?;
858+ if !left.truthy() {
859+ return Ok(left);
860+ }
861+ eval(b, scope)?
862+ }
863+ Expr::Or(a, b) => {
864+ let left = eval(a, scope)?;
865+ if left.truthy() {
866+ return Ok(left);
867+ }
868+ eval(b, scope)?
869+ }
870+ Expr::Compare(op, a, b) => {
871+ let left = eval(a, scope)?.into_value();
872+ let right = eval(b, scope)?.into_value();
873+ let result = match op {
874+ CmpOp::Eq => loose_eq(&left, &right),
875+ CmpOp::Ne => !loose_eq(&left, &right),
876+ CmpOp::Lt => compare(&left, &right) == Some(Ordering::Less),
877+ CmpOp::Le => matches!(
878+ compare(&left, &right),
879+ Some(Ordering::Less | Ordering::Equal)
880+ ),
881+ CmpOp::Gt => compare(&left, &right) == Some(Ordering::Greater),
882+ CmpOp::Ge => {
883+ matches!(
884+ compare(&left, &right),
885+ Some(Ordering::Greater | Ordering::Equal)
886+ )
887+ }
888+ };
889+ owned(Value::Bool(result))
890+ }
891+ Expr::Call(func, args) => owned(call(*func, args, scope)?),
892+ })
893+}
894+
895+fn call(func: Func, args: &[Expr], scope: &Scope) -> Result<Value, String> {
896+ let status = scope.status;
897+ match func {
898+ Func::Success => return Ok(Value::Bool(status == Status::Success)),
899+ Func::Failure => return Ok(Value::Bool(status == Status::Failure)),
900+ Func::Cancelled => return Ok(Value::Bool(status == Status::Cancelled)),
901+ Func::Always => return Ok(Value::Bool(true)),
902+ _ => {}
903+ }
904+ let values: Vec<Value> = args
905+ .iter()
906+ .map(|a| eval(a, scope).map(Ev::into_value))
907+ .collect::<Result<_, _>>()?;
908+ Ok(match func {
909+ Func::Contains => Value::Bool(match &values[0] {
910+ Value::Array(items) => items.iter().any(|item| loose_eq(item, &values[1])),
911+ search => upper(&to_text(search)).contains(&upper(&to_text(&values[1]))),
912+ }),
913+ Func::StartsWith => {
914+ Value::Bool(upper(&to_text(&values[0])).starts_with(&upper(&to_text(&values[1]))))
915+ }
916+ Func::EndsWith => {
917+ Value::Bool(upper(&to_text(&values[0])).ends_with(&upper(&to_text(&values[1]))))
918+ }
919+ Func::Format => Value::String(format_string(&values)?),
920+ Func::Join => {
921+ let separator = values.get(1).map_or_else(|| ",".to_string(), to_text);
922+ Value::String(match &values[0] {
923+ Value::Array(items) => items
924+ .iter()
925+ .map(to_text)
926+ .collect::<Vec<_>>()
927+ .join(&separator),
928+ other => to_text(other),
929+ })
930+ }
931+ Func::ToJson => Value::String(to_json(&values[0])),
932+ Func::FromJson => {
933+ let text = to_text(&values[0]);
934+ let parsed: Value = serde_json::from_str(&text)
935+ .map_err(|e| format!("Error from function 'fromJSON': {e}. Input: '{text}'"))?;
936+ normalize(parsed)
937+ }
938+ Func::HashFiles => {
939+ let patterns: Vec<String> = values.iter().map(to_text).collect();
940+ Value::String(scope.hash_files.map(|f| f(&patterns)).unwrap_or_default())
941+ }
942+ Func::Success | Func::Failure | Func::Cancelled | Func::Always => unreachable!(),
943+ })
944+}
945+
946+/// format('{0} {1}', ...): `{N}` is the Nth argument after the format string,
947+/// `{{` and `}}` are literal braces, anything else with a brace is an error.
948+fn format_string(values: &[Value]) -> Result<String, String> {
949+ let template = to_text(&values[0]);
950+ let args: Vec<String> = values[1..].iter().map(to_text).collect();
951+ let invalid = || format!("The following format string is invalid: '{template}'");
952+ let chars: Vec<char> = template.chars().collect();
953+ let mut out = String::new();
954+ let mut i = 0;
955+ while i < chars.len() {
956+ match chars[i] {
957+ '{' if chars.get(i + 1) == Some(&'{') => {
958+ out.push('{');
959+ i += 2;
960+ }
961+ '}' if chars.get(i + 1) == Some(&'}') => {
962+ out.push('}');
963+ i += 2;
964+ }
965+ '{' => {
966+ let start = i + 1;
967+ let mut end = start;
968+ while end < chars.len() && chars[end].is_ascii_digit() {
969+ end += 1;
970+ }
971+ if end == start || chars.get(end) != Some(&'}') {
972+ return Err(invalid());
973+ }
974+ let digits: String = chars[start..end].iter().collect();
975+ let index: usize = digits.parse().map_err(|_| invalid())?;
976+ let arg = args.get(index).ok_or_else(|| {
977+ format!(
978+ "The following format string references more arguments than were supplied: '{template}'"
979+ )
980+ })?;
981+ out.push_str(arg);
982+ i = end + 1;
983+ }
984+ '}' => return Err(invalid()),
985+ c => {
986+ out.push(c);
987+ i += 1;
988+ }
989+ }
990+ }
991+ Ok(out)
992+}
993+
994+fn upper(s: &str) -> String {
995+ s.to_uppercase()
996+}
997+
998+/// A value coerced to a number, as GitHub does when operand types differ.
999+fn to_number(value: &Value) -> f64 {
1000+ match value {
1001+ Value::Null => 0.0,
1002+ Value::Bool(b) => f64::from(u8::from(*b)),
1003+ Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
1004+ Value::String(s) => parse_number(s, true).unwrap_or(f64::NAN),
1005+ Value::Array(_) | Value::Object(_) => f64::NAN,
1006+ }
1007+}
1008+
1009+/// GitHub's `==`: same types compare directly (strings ignoring case; arrays
1010+/// and objects are never equal, since they cannot be the same instance here);
1011+/// different types are both coerced to numbers, and NaN equals nothing.
1012+fn loose_eq(a: &Value, b: &Value) -> bool {
1013+ match (a, b) {
1014+ (Value::Null, Value::Null) => true,
1015+ (Value::Bool(x), Value::Bool(y)) => x == y,
1016+ (Value::Number(_), Value::Number(_)) => to_number(a) == to_number(b),
1017+ (Value::String(x), Value::String(y)) => upper(x) == upper(y),
1018+ (Value::Array(_), Value::Array(_)) | (Value::Object(_), Value::Object(_)) => false,
1019+ _ => to_number(a) == to_number(b),
1020+ }
1021+}
1022+
1023+/// GitHub's ordering for `<`, `<=`, `>`, `>=`; None when they do not compare.
1024+fn compare(a: &Value, b: &Value) -> Option<Ordering> {
1025+ match (a, b) {
1026+ (Value::Null, Value::Null) => Some(Ordering::Equal),
1027+ (Value::String(x), Value::String(y)) => Some(upper(x).cmp(&upper(y))),
1028+ (Value::Array(_) | Value::Object(_), _) | (_, Value::Array(_) | Value::Object(_)) => None,
1029+ _ => to_number(a).partial_cmp(&to_number(b)),
1030+ }
1031+}
1032+
1033+/// A number as a JSON value, integral numbers as integers so they print and
1034+/// serialize without a trailing ".0".
1035+fn number(n: f64) -> Value {
1036+ if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 {
1037+ Value::from(n as i64)
1038+ } else {
1039+ serde_json::Number::from_f64(n).map_or(Value::Null, Value::Number)
1040+ }
1041+}
1042+
1043+/// Integral floats as integers, all the way down.
1044+fn normalize(value: Value) -> Value {
1045+ match value {
1046+ Value::Number(n) if n.is_f64() => number(n.as_f64().unwrap_or(f64::NAN)),
1047+ Value::Array(items) => Value::Array(items.into_iter().map(normalize).collect()),
1048+ Value::Object(map) => {
1049+ Value::Object(map.into_iter().map(|(k, v)| (k, normalize(v))).collect())
1050+ }
1051+ other => other,
1052+ }
1053+}
1054+
1055+fn to_json(value: &Value) -> String {
1056+ serde_json::to_string_pretty(&normalize(value.clone())).unwrap_or_default()
1057+}
1058+
1059+/// A number the way GitHub (.NET's "G15") writes it: up to 15 significant
1060+/// digits, no trailing zeros, and scientific notation (`1E+15`, `1E-07`) for
1061+/// very large or very small magnitudes.
1062+fn format_number(n: f64) -> String {
1063+ if n.is_nan() {
1064+ return "NaN".to_string();
1065+ }
1066+ if n.is_infinite() {
1067+ return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_string();
1068+ }
1069+ if n == 0.0 {
1070+ return "0".to_string();
1071+ }
1072+ let scientific = format!("{:.14e}", n.abs());
1073+ let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0"));
1074+ let exponent: i32 = exponent.parse().unwrap_or(0);
1075+ let mut digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
1076+ while digits.len() > 1 && digits.ends_with('0') {
1077+ digits.pop();
1078+ }
1079+ let mut out = String::new();
1080+ if n < 0.0 {
1081+ out.push('-');
1082+ }
1083+ if !(-5..15).contains(&exponent) {
1084+ out.push_str(&digits[..1]);
1085+ if digits.len() > 1 {
1086+ out.push('.');
1087+ out.push_str(&digits[1..]);
1088+ }
1089+ out.push('E');
1090+ out.push(if exponent < 0 { '-' } else { '+' });
1091+ out.push_str(&format!("{:02}", exponent.abs()));
1092+ } else if exponent >= 0 {
1093+ let whole = exponent as usize + 1;
1094+ if digits.len() > whole {
1095+ out.push_str(&digits[..whole]);
1096+ out.push('.');
1097+ out.push_str(&digits[whole..]);
1098+ } else {
1099+ out.push_str(&digits);
1100+ out.push_str(&"0".repeat(whole - digits.len()));
1101+ }
1102+ } else {
1103+ out.push_str("0.");
1104+ out.push_str(&"0".repeat((-exponent - 1) as usize));
1105+ out.push_str(&digits);
1106+ }
1107+ out
1108+}
1109+
1110+// ---------------------------------------------------------------------------
1111+
1112+#[cfg(test)]
1113+mod tests {
1114+ use super::*;
1115+ use serde_json::json;
1116+
1117+ fn contexts() -> Map<String, Value> {
1118+ let value = json!({
1119+ "github": {
1120+ "ref": "refs/heads/main",
1121+ "ref_name": "main",
1122+ "event_name": "push",
1123+ "repository": "syntaqx/g1t",
1124+ "actor": "dependabot[bot]",
1125+ "event": {
1126+ "pull_request": {
1127+ "number": 42,
1128+ "draft": false,
1129+ "title": "Fix the thing",
1130+ "head": { "ref": "feature/x" },
1131+ "labels": [{ "name": "bug" }, { "name": "Enhancement" }]
1132+ },
1133+ "issues": [
1134+ { "labels": [{ "name": "a" }, { "name": "b" }] },
1135+ { "labels": [{ "name": "c" }] }
1136+ ],
1137+ "head_commit": { "message": "fix: x [skip ci]" }
1138+ }
1139+ },
1140+ "env": { "NODE_VERSION": "18", "EMPTY": "" },
1141+ "vars": { "DEPLOY": "yes" },
1142+ "secrets": { "TOKEN": "s3cret" },
1143+ "inputs": { "debug": "true", "flag": true, "count": 3, "environment": "staging" },
1144+ "matrix": { "os": "ubuntu-latest", "node": 18, "experimental": false },
1145+ "strategy": { "fail-fast": true, "job-index": 0 },
1146+ "steps": {
1147+ "build": { "outputs": { "version": "1.2.3" }, "outcome": "success", "conclusion": "success" },
1148+ "test": { "outputs": {}, "outcome": "failure", "conclusion": "success" },
1149+ "my-step": { "outputs": { "cache-hit": "true" } }
1150+ },
1151+ "needs": {
1152+ "setup": {
1153+ "result": "success",
1154+ "outputs": { "matrix": "{\"os\":[\"ubuntu-latest\",\"windows-latest\"],\"node\":[18,20]}" }
1155+ }
1156+ },
1157+ "runner": { "os": "Linux", "arch": "X64" },
1158+ "job": { "status": "success" }
1159+ });
1160+ match value {
1161+ Value::Object(map) => map,
1162+ _ => unreachable!(),
1163+ }
1164+ }
1165+
1166+ fn with<T>(status: Status, f: impl FnOnce(&Scope) -> T) -> T {
1167+ let contexts = contexts();
1168+ let hash = |patterns: &[String]| format!("hash({})", patterns.join("|"));
1169+ let scope = Scope {
1170+ contexts: &contexts,
1171+ status,
1172+ hash_files: Some(&hash),
1173+ };
1174+ f(&scope)
1175+ }
1176+
1177+ fn ev(expression: &str) -> Value {
1178+ with(Status::Success, |s| evaluate(expression, s))
1179+ .unwrap_or_else(|e| panic!("{expression}: {e}"))
1180+ }
1181+
1182+ fn err(expression: &str) -> String {
1183+ with(Status::Success, |s| evaluate(expression, s)).unwrap_err()
1184+ }
1185+
1186+ fn cond(status: Status, text: &str) -> bool {
1187+ with(status, |s| condition(text, s)).unwrap_or_else(|e| panic!("{text}: {e}"))
1188+ }
1189+
1190+ #[test]
1191+ fn literals() {
1192+ assert_eq!(ev("null"), Value::Null);
1193+ assert_eq!(ev("true"), json!(true));
1194+ assert_eq!(ev("false"), json!(false));
1195+ assert_eq!(ev("711"), json!(711));
1196+ assert_eq!(ev("-9.2"), json!(-9.2));
1197+ assert_eq!(ev("0xff"), json!(255));
1198+ assert_eq!(ev("-2.99e-2"), json!(-0.0299));
1199+ assert_eq!(ev("1e3"), json!(1000));
1200+ assert_eq!(ev("'Mona the Octocat'"), json!("Mona the Octocat"));
1201+ assert_eq!(ev("'It''s open source!'"), json!("It's open source!"));
1202+ }
1203+
1204+ #[test]
1205+ fn double_quotes_are_an_error() {
1206+ let e = err("github.ref == \"main\"");
1207+ assert!(
1208+ e.starts_with("Unexpected symbol: '\"'. Located at position 15 within expression:"),
1209+ "{e}"
1210+ );
1211+ }
1212+
1213+ #[test]
1214+ fn ref_is_main() {
1215+ assert_eq!(ev("github.ref == 'refs/heads/main'"), json!(true));
1216+ assert_eq!(ev("github.ref != 'refs/heads/main'"), json!(false));
1217+ }
1218+
1219+ #[test]
1220+ fn starts_with_tag() {
1221+ assert_eq!(ev("startsWith(github.ref, 'refs/tags/v')"), json!(false));
1222+ assert_eq!(ev("startsWith(github.ref, 'REFS/heads/')"), json!(true));
1223+ assert_eq!(ev("endsWith(github.repository, '/G1T')"), json!(true));
1224+ }
1225+
1226+ #[test]
1227+ fn contains_labels_filter() {
1228+ assert_eq!(
1229+ ev("contains(github.event.pull_request.labels.*.name, 'bug')"),
1230+ json!(true)
1231+ );
1232+ assert_eq!(
1233+ ev("contains(github.event.pull_request.labels.*.name, 'enhancement')"),
1234+ json!(true)
1235+ );
1236+ assert_eq!(
1237+ ev("contains(github.event.pull_request.labels.*.name, 'docs')"),
1238+ json!(false)
1239+ );
1240+ }
1241+
1242+ #[test]
1243+ fn nested_object_filters_flatten() {
1244+ assert_eq!(
1245+ ev("github.event.issues.*.labels.*.name"),
1246+ json!(["a", "b", "c"])
1247+ );
1248+ assert_eq!(
1249+ ev("github.event.pull_request.labels[*].name"),
1250+ json!(["bug", "Enhancement"])
1251+ );
1252+ assert_eq!(
1253+ ev("github.event.pull_request.*"),
1254+ ev("github.event.pull_request.*")
1255+ );
1256+ assert_eq!(ev("matrix.nothing.*"), json!([]));
1257+ }
1258+
1259+ #[test]
1260+ fn matrix_and() {
1261+ assert_eq!(
1262+ ev("matrix.os == 'ubuntu-latest' && matrix.node >= 18"),
1263+ json!(true)
1264+ );
1265+ assert_eq!(
1266+ ev("matrix.os == 'windows-latest' && matrix.node >= 18"),
1267+ json!(false)
1268+ );
1269+ }
1270+
1271+ #[test]
1272+ fn step_outputs() {
1273+ assert_eq!(ev("steps.build.outputs.version"), json!("1.2.3"));
1274+ assert_eq!(
1275+ ev("steps.my-step.outputs.cache-hit != 'true'"),
1276+ json!(false)
1277+ );
1278+ assert_eq!(ev("steps.missing.outputs.version"), Value::Null);
1279+ }
1280+
1281+ #[test]
1282+ fn format_with_hash_files() {
1283+ assert_eq!(
1284+ ev("format('{0}-{1}', runner.os, hashFiles('**/package-lock.json'))"),
1285+ json!("Linux-hash(**/package-lock.json)")
1286+ );
1287+ assert_eq!(ev("hashFiles('a', 'b')"), json!("hash(a|b)"));
1288+ }
1289+
1290+ #[test]
1291+ fn hash_files_without_sandbox_is_empty() {
1292+ let contexts = contexts();
1293+ let scope = Scope {
1294+ contexts: &contexts,
1295+ status: Status::Success,
1296+ hash_files: None,
1297+ };
1298+ assert_eq!(
1299+ evaluate("hashFiles('**/*.lock')", &scope).unwrap(),
1300+ json!("")
1301+ );
1302+ }
1303+
1304+ #[test]
1305+ fn format_escapes_and_errors() {
1306+ assert_eq!(
1307+ ev("format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')"),
1308+ json!("{Hello Mona the Octocat!}")
1309+ );
1310+ assert_eq!(ev("format('{0}{0}', 1)"), json!("11"));
1311+ assert!(err("format('{1}', 'a')").contains("more arguments than were supplied"));
1312+ assert!(err("format('{0', 'a')").contains("invalid"));
1313+ }
1314+
1315+ #[test]
1316+ fn from_json_matrix() {
1317+ assert_eq!(
1318+ ev("fromJSON(needs.setup.outputs.matrix)"),
1319+ json!({ "os": ["ubuntu-latest", "windows-latest"], "node": [18, 20] })
1320+ );
1321+ assert_eq!(
1322+ ev("fromJSON(needs.setup.outputs.matrix).node[1]"),
1323+ json!(20)
1324+ );
1325+ assert_eq!(ev("fromJSON('true')"), json!(true));
1326+ assert_eq!(ev("fromJSON('3.0')"), json!(3));
1327+ assert!(err("fromJSON('{nope')").contains("fromJSON"));
1328+ }
1329+
1330+ #[test]
1331+ fn event_name_or() {
1332+ assert_eq!(
1333+ ev("github.event_name == 'push' || github.event_name == 'workflow_dispatch'"),
1334+ json!(true)
1335+ );
1336+ }
1337+
1338+ #[test]
1339+ fn and_or_return_operands() {
1340+ assert_eq!(ev("matrix.os && 'yes'"), json!("yes"));
1341+ assert_eq!(ev("env.EMPTY && 'yes'"), json!(""));
1342+ assert_eq!(ev("env.EMPTY || 'fallback'"), json!("fallback"));
1343+ assert_eq!(ev("inputs.environment || 'production'"), json!("staging"));
1344+ assert_eq!(ev("github.event.pull_request.draft || null"), Value::Null);
1345+ }
1346+
1347+ #[test]
1348+ fn short_circuit_skips_errors() {
1349+ assert_eq!(ev("false && fromJSON('{bad')"), json!(false));
1350+ assert_eq!(ev("true || fromJSON('{bad')"), json!(true));
1351+ }
1352+
1353+ #[test]
1354+ fn not_cancelled() {
1355+ assert!(cond(Status::Success, "!cancelled()"));
1356+ assert!(cond(Status::Failure, "!cancelled()"));
1357+ assert!(!cond(Status::Cancelled, "!cancelled()"));
1358+ }
1359+
1360+ #[test]
1361+ fn failure_and_outcome() {
1362+ assert!(cond(
1363+ Status::Failure,
1364+ "failure() && steps.test.outcome == 'failure'"
1365+ ));
1366+ assert!(!cond(
1367+ Status::Success,
1368+ "failure() && steps.test.outcome == 'failure'"
1369+ ));
1370+ assert!(!cond(
1371+ Status::Failure,
1372+ "failure() && steps.build.outcome == 'failure'"
1373+ ));
1374+ }
1375+
1376+ #[test]
1377+ fn string_input_is_not_true() {
1378+ // The famous gotcha: 'true' coerces to NaN when compared with a bool.
1379+ assert_eq!(ev("inputs.debug == true"), json!(false));
1380+ assert_eq!(ev("inputs.debug == 'true'"), json!(true));
1381+ assert_eq!(ev("inputs.flag == true"), json!(true));
1382+ }
1383+
1384+ #[test]
1385+ fn coercions() {
1386+ assert_eq!(ev("'' == 0"), json!(true));
1387+ assert_eq!(ev("null == false"), json!(true));
1388+ assert_eq!(ev("null == 0"), json!(true));
1389+ assert_eq!(ev("1 == '1'"), json!(true));
1390+ assert_eq!(ev("'1.0' == 1"), json!(true));
1391+ assert_eq!(ev("' 2 ' == 2"), json!(true));
1392+ assert_eq!(ev("'0x10' == 16"), json!(true));
1393+ assert_eq!(ev("true == 1"), json!(true));
1394+ assert_eq!(ev("'abc' == 0"), json!(false));
1395+ assert_eq!(ev("'abc' != 0"), json!(true));
1396+ assert_eq!(ev("null == ''"), json!(true));
1397+ }
1398+
1399+ #[test]
1400+ fn hex_and_exponent() {
1401+ assert_eq!(ev("0x10 == 16"), json!(true));
1402+ assert_eq!(ev("1e3 == 1000"), json!(true));
1403+ assert_eq!(ev("-0x10 < 0"), json!(true));
1404+ }
1405+
1406+ #[test]
1407+ fn case_insensitive_strings() {
1408+ assert_eq!(ev("'ABC' == 'abc'"), json!(true));
1409+ assert_eq!(ev("contains('Hello World', 'WORLD')"), json!(true));
1410+ assert_eq!(ev("'a' < 'B'"), json!(true));
1411+ }
1412+
1413+ #[test]
1414+ fn case_insensitive_names() {
1415+ assert_eq!(ev("GitHub.Event_Name"), json!("push"));
1416+ assert_eq!(ev("ENV.node_version"), json!("18"));
1417+ assert_eq!(ev("StartsWith(github.ref, 'refs/')"), json!(true));
1418+ assert_eq!(ev("TOJSON(1)"), json!("1"));
1419+ }
1420+
1421+ #[test]
1422+ fn objects_and_arrays_are_never_equal() {
1423+ assert_eq!(ev("github.event == github.event"), json!(false));
1424+ assert_eq!(ev("fromJSON('[]') == fromJSON('[]')"), json!(false));
1425+ assert_eq!(ev("fromJSON('[]') == 0"), json!(false));
1426+ assert_eq!(ev("fromJSON('{}') < 1"), json!(false));
1427+ }
1428+
1429+ #[test]
1430+ fn nan_compares_false() {
1431+ assert_eq!(ev("'abc' < 1"), json!(false));
1432+ assert_eq!(ev("'abc' >= 1"), json!(false));
1433+ assert_eq!(ev("'abc' == 'abc'"), json!(true));
1434+ }
1435+
1436+ #[test]
1437+ fn comparisons() {
1438+ assert_eq!(ev("matrix.node > 16"), json!(true));
1439+ assert_eq!(ev("matrix.node <= '18'"), json!(true));
1440+ assert_eq!(ev("inputs.count < 3"), json!(false));
1441+ assert_eq!(ev("null <= null"), json!(true));
1442+ assert_eq!(ev("false < true"), json!(true));
1443+ }
1444+
1445+ #[test]
1446+ fn precedence() {
1447+ // ! binds tighter than ==, == tighter than &&, && tighter than ||.
1448+ assert_eq!(ev("!matrix.experimental == true"), json!(true));
1449+ assert_eq!(ev("true || false && false"), json!(true));
1450+ assert_eq!(ev("(true || false) && false"), json!(false));
1451+ assert_eq!(ev("1 < 2 == true"), json!(true));
1452+ assert_eq!(ev("!!'x'"), json!(true));
1453+ }
1454+
1455+ #[test]
1456+ fn indexing() {
1457+ assert_eq!(ev("github['event']['pull_request']['number']"), json!(42));
1458+ assert_eq!(ev("github.event.pull_request.labels[0].name"), json!("bug"));
1459+ assert_eq!(ev("github.event.pull_request.labels[5]"), Value::Null);
1460+ assert_eq!(ev("matrix['os']"), json!("ubuntu-latest"));
1461+ assert_eq!(ev("strategy.fail-fast"), json!(true));
1462+ assert_eq!(ev("strategy['job-index']"), json!(0));
1463+ }
1464+
1465+ #[test]
1466+ fn missing_properties_are_null() {
1467+ assert_eq!(ev("github.event.release.tag_name"), Value::Null);
1468+ assert_eq!(ev("github.ref.nope"), Value::Null);
1469+ assert_eq!(ev("jobs.anything"), Value::Null);
1470+ }
1471+
1472+ #[test]
1473+ fn unrecognized_named_value() {
1474+ let e = err("foo.bar == 1");
1475+ assert_eq!(
1476+ e,
1477+ "Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar == 1"
1478+ );
1479+ // Found at parse time, even in a branch that never runs.
1480+ assert!(err("false && bogus").contains("Unrecognized named-value: 'bogus'"));
1481+ }
1482+
1483+ #[test]
1484+ fn function_errors() {
1485+ assert!(err("nope(1)").starts_with("Unrecognized function: 'nope'"));
1486+ assert!(err("contains('a')").starts_with("Too few parameters supplied: 'contains'"));
1487+ assert!(err("success(1)").starts_with("Too many parameters supplied: 'success'"));
1488+ assert!(err("toJSON()").starts_with("Too few parameters supplied: 'toJSON'"));
1489+ }
1490+
1491+ #[test]
1492+ fn syntax_errors() {
1493+ assert!(err("github.ref ==").starts_with("Unexpected end of expression: '=='"));
1494+ assert!(err("(true").starts_with("Unexpected end of expression"));
1495+ assert!(err("true false").starts_with("Unexpected symbol: 'false'. Located at position 6"));
1496+ assert!(err("'open").starts_with("Unexpected symbol: ''open'"));
1497+ assert!(err("a = b").contains("Unexpected symbol"));
1498+ assert!(err("github.").starts_with("Unexpected end of expression"));
1499+ assert!(err("").contains("expression was expected"));
1500+ }
1501+
1502+ #[test]
1503+ fn contains_array_uses_loose_equality() {
1504+ assert_eq!(ev("contains(fromJSON('[1, 2, 3]'), '2')"), json!(true));
1505+ assert_eq!(
1506+ ev("contains(fromJSON('[\"push\", \"pull_request\"]'), github.event_name)"),
1507+ json!(true)
1508+ );
1509+ assert_eq!(
1510+ ev("contains(github.event.head_commit.message, '[skip ci]')"),
1511+ json!(true)
1512+ );
1513+ assert_eq!(ev("contains(github.actor, '[bot]')"), json!(true));
1514+ }
1515+
1516+ #[test]
1517+ fn join() {
1518+ assert_eq!(
1519+ ev("join(github.event.pull_request.labels.*.name)"),
1520+ json!("bug,Enhancement")
1521+ );
1522+ assert_eq!(
1523+ ev("join(github.event.pull_request.labels.*.name, ', ')"),
1524+ json!("bug, Enhancement")
1525+ );
1526+ assert_eq!(ev("join('abc', '-')"), json!("abc"));
1527+ assert_eq!(
1528+ ev("join(fromJSON('[1, true, null]'), ' ')"),
1529+ json!("1 true ")
1530+ );
1531+ }
1532+
1533+ #[test]
1534+ fn to_json_pretty() {
1535+ assert_eq!(
1536+ ev("toJSON(steps.build.outputs)"),
1537+ json!("{\n \"version\": \"1.2.3\"\n}")
1538+ );
1539+ assert_eq!(ev("toJSON('a')"), json!("\"a\""));
1540+ assert_eq!(ev("toJSON(null)"), json!("null"));
1541+ assert_eq!(ev("toJSON(0x10)"), json!("16"));
1542+ }
1543+
1544+ #[test]
1545+ fn truthiness() {
1546+ assert!(!truthy(&json!(false)));
1547+ assert!(!truthy(&json!(0)));
1548+ assert!(!truthy(&json!(-0.0)));
1549+ assert!(!truthy(&json!("")));
1550+ assert!(!truthy(&Value::Null));
1551+ assert!(truthy(&json!("0")));
1552+ assert!(truthy(&json!("false")));
1553+ assert!(truthy(&json!([])));
1554+ assert!(truthy(&json!({})));
1555+ assert!(truthy(&json!(0.5)));
1556+ }
1557+
1558+ #[test]
1559+ fn text_conversion() {
1560+ assert_eq!(to_text(&Value::Null), "");
1561+ assert_eq!(to_text(&json!(true)), "true");
1562+ assert_eq!(to_text(&json!(3.0)), "3");
1563+ assert_eq!(to_text(&json!(1.5)), "1.5");
1564+ assert_eq!(to_text(&json!(-0.0299)), "-0.0299");
1565+ assert_eq!(to_text(&json!(1e20)), "1E+20");
1566+ assert_eq!(to_text(&json!(0.0000001)), "1E-07");
1567+ assert_eq!(to_text(&json!(123456789012345_i64)), "123456789012345");
1568+ assert_eq!(to_text(&json!(["a", 1])), "Array");
1569+ assert_eq!(to_text(&json!({ "a": 1 })), "Object");
1570+ assert_eq!(to_text(&json!("as-is")), "as-is");
1571+ }
1572+
1573+ #[test]
1574+ fn condition_implicit_success() {
1575+ assert!(cond(Status::Success, "github.event_name == 'push'"));
1576+ assert!(!cond(Status::Failure, "github.event_name == 'push'"));
1577+ assert!(!cond(Status::Cancelled, "github.event_name == 'push'"));
1578+ assert!(!cond(
1579+ Status::Success,
1580+ "github.event_name == 'pull_request'"
1581+ ));
1582+ }
1583+
1584+ #[test]
1585+ fn condition_status_functions() {
1586+ assert!(cond(Status::Failure, "always()"));
1587+ assert!(cond(Status::Cancelled, "always()"));
1588+ assert!(cond(Status::Failure, "failure()"));
1589+ assert!(!cond(Status::Success, "failure()"));
1590+ assert!(cond(Status::Cancelled, "cancelled()"));
1591+ assert!(cond(Status::Success, "success()"));
1592+ assert!(cond(
1593+ Status::Failure,
1594+ "${{ always() && github.ref == 'refs/heads/main' }}"
1595+ ));
1596+ // Nested inside another call still counts.
1597+ assert!(cond(Status::Failure, "contains(toJSON(always()), 'true')"));
1598+ }
1599+
1600+ #[test]
1601+ fn condition_status_not_by_substring() {
1602+ // 'failure()' inside a string is not a call; implicit success() applies.
1603+ assert!(!cond(Status::Failure, "steps.test.outcome != 'failure()'"));
1604+ assert!(cond(Status::Success, "steps.test.outcome != 'failure()'"));
1605+ }
1606+
1607+ #[test]
1608+ fn condition_wrapped_and_empty() {
1609+ assert!(cond(
1610+ Status::Success,
1611+ "${{ github.ref == 'refs/heads/main' }}"
1612+ ));
1613+ assert!(!cond(
1614+ Status::Success,
1615+ " ${{ github.ref == 'refs/heads/dev' }} "
1616+ ));
1617+ assert!(cond(Status::Success, ""));
1618+ assert!(!cond(Status::Failure, ""));
1619+ assert!(cond(Status::Success, "${{ }}"));
1620+ assert!(cond(Status::Success, "${{ matrix.os }}"));
1621+ assert!(!cond(Status::Success, "${{ env.EMPTY }}"));
1622+ assert!(cond(
1623+ Status::Success,
1624+ "vars.DEPLOY == 'yes' && !github.event.pull_request.draft"
1625+ ));
1626+ }
1627+
1628+ #[test]
1629+ fn condition_with_text_around_is_a_string() {
1630+ // On GitHub this is always true: it's the string "false && x", not an expression.
1631+ assert!(cond(Status::Success, "${{ false }} && x"));
1632+ }
1633+
1634+ #[test]
1635+ fn interpolate_mixed_text() {
1636+ let out = with(Status::Success, |s| {
1637+ interpolate(
1638+ "node-${{ matrix.node }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}",
1639+ s,
1640+ )
1641+ })
1642+ .unwrap();
1643+ assert_eq!(out, "node-18-Linux-hash(**/yarn.lock)");
1644+ let out = with(Status::Success, |s| {
1645+ interpolate("echo \"PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}\"", s)
1646+ })
1647+ .unwrap();
1648+ assert_eq!(out, "echo \"PR #42: Fix the thing\"");
1649+ }
1650+
1651+ #[test]
1652+ fn interpolate_edge_cases() {
1653+ assert_eq!(
1654+ with(Status::Success, |s| interpolate("plain $text {{ x }}", s)).unwrap(),
1655+ "plain $text {{ x }}"
1656+ );
1657+ assert_eq!(
1658+ with(Status::Success, |s| interpolate("${{ '}}' }}!", s)).unwrap(),
1659+ "}}!"
1660+ );
1661+ assert_eq!(
1662+ with(Status::Success, |s| interpolate("[${{ env.MISSING }}]", s)).unwrap(),
1663+ "[]"
1664+ );
1665+ assert_eq!(
1666+ with(Status::Success, |s| interpolate("${{ 1.50 }}", s)).unwrap(),
1667+ "1.5"
1668+ );
1669+ assert!(
1670+ with(Status::Success, |s| interpolate("oops ${{ github.ref", s))
1671+ .unwrap_err()
1672+ .contains("not closed")
1673+ );
1674+ assert!(with(Status::Success, |s| interpolate("${{ \"x\" }}", s)).is_err());
1675+ }
1676+
1677+ #[test]
1678+ fn interpolate_value_keeps_types() {
1679+ let input = json!({
1680+ "matrix": "${{ fromJSON(needs.setup.outputs.matrix) }}",
1681+ "continue-on-error": "${{ matrix.experimental }}",
1682+ "timeout-minutes": "${{ inputs.count }}",
1683+ "name": "Build ${{ matrix.os }}",
1684+ "env": { "VERSION_${{ matrix.node }}": "${{ steps.build.outputs.version }}" },
1685+ "list": ["${{ github.event.pull_request.labels.*.name }}", 7, null],
1686+ "plain": true
1687+ });
1688+ let out = with(Status::Success, |s| interpolate_value(&input, s)).unwrap();
1689+ assert_eq!(
1690+ out,
1691+ json!({
1692+ "matrix": { "os": ["ubuntu-latest", "windows-latest"], "node": [18, 20] },
1693+ "continue-on-error": false,
1694+ "timeout-minutes": 3,
1695+ "name": "Build ubuntu-latest",
1696+ "env": { "VERSION_18": "1.2.3" },
1697+ "list": [["bug", "Enhancement"], 7, null],
1698+ "plain": true
1699+ })
1700+ );
1701+ }
1702+
1703+ #[test]
1704+ fn has_expression_detects() {
1705+ assert!(has_expression("a ${{ b }}"));
1706+ assert!(!has_expression("a ${ b }"));
1707+ assert!(!has_expression("{{ b }}"));
1708+ }
1709+
1710+ #[test]
1711+ fn dependabot_and_draft_guards() {
1712+ assert!(!cond(Status::Success, "github.actor != 'dependabot[bot]'"));
1713+ assert!(cond(
1714+ Status::Success,
1715+ "github.event.pull_request.draft == false"
1716+ ));
1717+ assert!(cond(
1718+ Status::Success,
1719+ "!contains(github.event.head_commit.message, '[skip deploy]')"
1720+ ));
1721+ }
1722+
1723+ #[test]
1724+ fn tag_release_condition() {
1725+ let contexts = {
1726+ let mut c = contexts();
1727+ c["github"]["ref"] = json!("refs/tags/v1.4.0");
1728+ c["github"]["event_name"] = json!("push");
1729+ c
1730+ };
1731+ let scope = Scope {
1732+ contexts: &contexts,
1733+ status: Status::Success,
1734+ hash_files: None,
1735+ };
1736+ assert!(
1737+ condition(
1738+ "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')",
1739+ &scope
1740+ )
1741+ .unwrap()
1742+ );
1743+ assert_eq!(
1744+ interpolate("${{ format('release-{0}', github.ref_name) }}", &scope).unwrap(),
1745+ "release-main"
1746+ );
1747+ }
1748+
1749+ #[test]
1750+ fn needs_result_and_number_format() {
1751+ assert!(cond(Status::Success, "needs.setup.result == 'success'"));
1752+ assert_eq!(ev("format('{0}', 0.1)"), json!("0.1"));
1753+ assert_eq!(ev("format('{0}', 100)"), json!("100"));
1754+ assert_eq!(ev("format('{0}|{1}', null, true)"), json!("|true"));
1755+ }
1756+}
+310−0
1+//! Branch, tag and path filters, with GitHub's pattern syntax:
2+//!
3+//! - `*` matches any characters except `/`; `**` matches any characters.
4+//! - `?` makes the character before it optional; `+` repeats it.
5+//! - `[a-z0-9]` matches one character of a set.
6+//! - `!` at the start of a pattern excludes what it matches. Patterns are
7+//! read in order and the last one that matches decides.
8+//! - `\` escapes the next character.
9+
10+#[derive(Clone, Debug, PartialEq, Eq)]
11+enum Atom {
12+ Char(char),
13+ Class(Vec<(char, char)>),
14+ /// `*`: anything but `/`.
15+ Star,
16+ /// `**`: anything.
17+ Globstar,
18+}
19+
20+#[derive(Clone, Debug, PartialEq, Eq)]
21+enum Repeat {
22+ One,
23+ /// `?`
24+ Optional,
25+ /// `+`
26+ OneOrMore,
27+}
28+
29+#[derive(Clone, Debug, PartialEq, Eq)]
30+struct Piece {
31+ atom: Atom,
32+ repeat: Repeat,
33+}
34+
35+/// One compiled pattern.
36+#[derive(Clone, Debug, PartialEq, Eq)]
37+pub struct Pattern {
38+ pieces: Vec<Piece>,
39+ pub negated: bool,
40+ pub source: String,
41+}
42+
43+impl Pattern {
44+ pub fn parse(source: &str) -> Pattern {
45+ let (negated, body) = match source.strip_prefix('!') {
46+ Some(rest) => (true, rest),
47+ None => (false, source),
48+ };
49+ let chars: Vec<char> = body.chars().collect();
50+ let mut pieces: Vec<Piece> = Vec::new();
51+ let mut i = 0;
52+ while i < chars.len() {
53+ let c = chars[i];
54+ match c {
55+ '*' if chars.get(i + 1) == Some(&'*') => {
56+ pieces.push(Piece { atom: Atom::Globstar, repeat: Repeat::One });
57+ i += 2;
58+ }
59+ '*' => {
60+ pieces.push(Piece { atom: Atom::Star, repeat: Repeat::One });
61+ i += 1;
62+ }
63+ '?' | '+' if pieces.last().is_some_and(|p| p.repeat == Repeat::One && !matches!(p.atom, Atom::Star | Atom::Globstar)) => {
64+ pieces.last_mut().expect("checked").repeat = if c == '?' { Repeat::Optional } else { Repeat::OneOrMore };
65+ i += 1;
66+ }
67+ '[' => {
68+ // A set, up to the next `]`; without one, a literal `[`.
69+ match chars[i + 1..].iter().position(|&x| x == ']') {
70+ Some(len) if len > 0 => {
71+ let inner = &chars[i + 1..i + 1 + len];
72+ let mut ranges = Vec::new();
73+ let mut j = 0;
74+ while j < inner.len() {
75+ if j + 2 < inner.len() && inner[j + 1] == '-' {
76+ ranges.push((inner[j], inner[j + 2]));
77+ j += 3;
78+ } else {
79+ ranges.push((inner[j], inner[j]));
80+ j += 1;
81+ }
82+ }
83+ pieces.push(Piece { atom: Atom::Class(ranges), repeat: Repeat::One });
84+ i += len + 2;
85+ }
86+ _ => {
87+ pieces.push(Piece { atom: Atom::Char('['), repeat: Repeat::One });
88+ i += 1;
89+ }
90+ }
91+ }
92+ '\\' if i + 1 < chars.len() => {
93+ pieces.push(Piece { atom: Atom::Char(chars[i + 1]), repeat: Repeat::One });
94+ i += 2;
95+ }
96+ other => {
97+ pieces.push(Piece { atom: Atom::Char(other), repeat: Repeat::One });
98+ i += 1;
99+ }
100+ }
101+ }
102+ Pattern { pieces, negated, source: source.to_owned() }
103+ }
104+
105+ /// Whether the text matches, ignoring `!`.
106+ pub fn matches(&self, text: &str) -> bool {
107+ let text: Vec<char> = text.chars().collect();
108+ matches_at(&self.pieces, &text)
109+ }
110+}
111+
112+fn atom_matches(atom: &Atom, c: char) -> bool {
113+ match atom {
114+ Atom::Char(expected) => *expected == c,
115+ Atom::Class(ranges) => ranges.iter().any(|(low, high)| (*low..=*high).contains(&c)),
116+ Atom::Star => c != '/',
117+ Atom::Globstar => true,
118+ }
119+}
120+
121+fn matches_at(pieces: &[Piece], text: &[char]) -> bool {
122+ let Some((piece, rest)) = pieces.split_first() else {
123+ return text.is_empty();
124+ };
125+ match (&piece.atom, &piece.repeat) {
126+ (Atom::Star | Atom::Globstar, _) => {
127+ // `**/` also matches nothing, so `**/README.md` finds the root's.
128+ if piece.atom == Atom::Globstar
129+ && rest.first().is_some_and(|next| next.atom == Atom::Char('/') && next.repeat == Repeat::One)
130+ && matches_at(&rest[1..], text)
131+ {
132+ return true;
133+ }
134+ // Zero or more, as long as each character is allowed.
135+ for taken in 0..=text.len() {
136+ if matches_at(rest, &text[taken..]) {
137+ return true;
138+ }
139+ if taken < text.len() && !atom_matches(&piece.atom, text[taken]) {
140+ return false;
141+ }
142+ }
143+ false
144+ }
145+ (atom, Repeat::One) => text.first().is_some_and(|&c| atom_matches(atom, c)) && matches_at(rest, &text[1..]),
146+ (atom, Repeat::Optional) => {
147+ matches_at(rest, text) || (text.first().is_some_and(|&c| atom_matches(atom, c)) && matches_at(rest, &text[1..]))
148+ }
149+ (atom, Repeat::OneOrMore) => {
150+ let mut taken = 0;
151+ while taken < text.len() && atom_matches(atom, text[taken]) {
152+ taken += 1;
153+ if matches_at(rest, &text[taken..]) {
154+ return true;
155+ }
156+ }
157+ false
158+ }
159+ }
160+}
161+
162+/// A list of patterns, read in order: a later `!pattern` excludes what an
163+/// earlier one included, and a later pattern can include it again.
164+#[derive(Clone, Debug, Default, PartialEq, Eq)]
165+pub struct Patterns(pub Vec<Pattern>);
166+
167+impl Patterns {
168+ pub fn new<S: AsRef<str>>(sources: &[S]) -> Patterns {
169+ Patterns(sources.iter().map(|source| Pattern::parse(source.as_ref())).collect())
170+ }
171+
172+ /// Whether the text is included.
173+ pub fn includes(&self, text: &str) -> bool {
174+ let mut included = false;
175+ for pattern in &self.0 {
176+ if pattern.matches(text) {
177+ included = !pattern.negated;
178+ }
179+ }
180+ included
181+ }
182+
183+ /// Whether any pattern matches the text (for `-ignore` lists, where
184+ /// `!` patterns put a text back).
185+ pub fn ignores(&self, text: &str) -> bool {
186+ self.includes(text)
187+ }
188+}
189+
190+/// A filter as a workflow gives it: `branches` or `branches-ignore`,
191+/// `tags` or `tags-ignore`, `paths` or `paths-ignore`.
192+#[derive(Clone, Debug, Default, PartialEq, Eq)]
193+pub struct Filter {
194+ pub only: Option<Patterns>,
195+ pub ignore: Option<Patterns>,
196+}
197+
198+impl Filter {
199+ pub fn is_set(&self) -> bool {
200+ self.only.is_some() || self.ignore.is_some()
201+ }
202+
203+ /// Whether one name (a branch or a tag) passes.
204+ pub fn allows(&self, name: &str) -> bool {
205+ if let Some(only) = &self.only
206+ && !only.includes(name)
207+ {
208+ return false;
209+ }
210+ if let Some(ignore) = &self.ignore
211+ && ignore.ignores(name)
212+ {
213+ return false;
214+ }
215+ true
216+ }
217+
218+ /// Whether a set of changed paths passes: `paths` needs at least one
219+ /// included path; `paths-ignore` needs at least one path not ignored.
220+ /// With no paths known, it passes.
221+ pub fn allows_paths(&self, paths: &[String]) -> bool {
222+ if paths.is_empty() {
223+ return true;
224+ }
225+ if let Some(only) = &self.only
226+ && !paths.iter().any(|path| only.includes(path))
227+ {
228+ return false;
229+ }
230+ if let Some(ignore) = &self.ignore
231+ && paths.iter().all(|path| ignore.ignores(path))
232+ {
233+ return false;
234+ }
235+ true
236+ }
237+}
238+
239+#[cfg(test)]
240+mod tests {
241+ use super::*;
242+
243+ fn m(pattern: &str, text: &str) -> bool {
244+ Pattern::parse(pattern).matches(text)
245+ }
246+
247+ #[test]
248+ fn stars_and_globstars() {
249+ assert!(m("main", "main"));
250+ assert!(!m("main", "mainline"));
251+ assert!(m("releases/*", "releases/v1"));
252+ assert!(!m("releases/*", "releases/v1/hotfix"));
253+ assert!(m("releases/**", "releases/v1/hotfix"));
254+ assert!(m("feature/**", "feature/a/b/c"));
255+ assert!(m("*", "main"));
256+ assert!(!m("*", "feature/x"));
257+ assert!(m("**", "feature/x"));
258+ assert!(m("**.js", "src/app/index.js"));
259+ assert!(m("*.js", "index.js"));
260+ assert!(!m("*.js", "src/index.js"));
261+ assert!(m("docs/**", "docs/guide/intro.md"));
262+ assert!(m("**/README.md", "a/b/README.md"));
263+ assert!(m("**/*.md", "README.md"));
264+ assert!(m("**/README.md", "README.md"));
265+ assert!(m("**/README.md", "server/README.md"));
266+ }
267+
268+ #[test]
269+ fn repeats_classes_and_escapes() {
270+ assert!(m("v[12].[0-9]+.[0-9]+", "v1.10.3"));
271+ assert!(!m("v[12].[0-9]+.[0-9]+", "v3.1.0"));
272+ assert!(m("v2*", "v2.0.0"));
273+ assert!(m("colou?r", "color"));
274+ assert!(m("colou?r", "colour"));
275+ assert!(m("v[0-9]+", "v123"));
276+ assert!(!m("v[0-9]+", "v"));
277+ assert!(m("a\\*b", "a*b"));
278+ assert!(!m("a\\*b", "axb"));
279+ }
280+
281+ #[test]
282+ fn order_decides_with_negations() {
283+ let list = Patterns::new(&["releases/**", "!releases/**-alpha"]);
284+ assert!(list.includes("releases/v1"));
285+ assert!(!list.includes("releases/v1-alpha"));
286+ let again = Patterns::new(&["**", "!docs/**", "docs/api/**"]);
287+ assert!(again.includes("src/a.rs"));
288+ assert!(!again.includes("docs/intro.md"));
289+ assert!(again.includes("docs/api/x.md"));
290+ }
291+
292+ #[test]
293+ fn filters_for_names_and_paths() {
294+ let branches = Filter { only: Some(Patterns::new(&["main", "release/**"])), ignore: None };
295+ assert!(branches.allows("main"));
296+ assert!(branches.allows("release/2.0"));
297+ assert!(!branches.allows("feature/x"));
298+ let ignored = Filter { only: None, ignore: Some(Patterns::new(&["dependabot/**"])) };
299+ assert!(!ignored.allows("dependabot/npm/x"));
300+ assert!(ignored.allows("main"));
301+
302+ let paths = Filter { only: Some(Patterns::new(&["src/**", "!src/**/*.md"])), ignore: None };
303+ assert!(paths.allows_paths(&["src/main.rs".into()]));
304+ assert!(!paths.allows_paths(&["src/notes.md".into(), "README.md".into()]));
305+ let docs = Filter { only: None, ignore: Some(Patterns::new(&["docs/**", "*.md"])) };
306+ assert!(!docs.allows_paths(&["docs/a.md".into(), "README.md".into()]));
307+ assert!(docs.allows_paths(&["docs/a.md".into(), "src/lib.rs".into()]));
308+ assert!(docs.allows_paths(&[]));
309+ }
310+}
+16−0
1+//! GitHub Actions on g1t. A repository's `.github/workflows/*.yml` run on
2+//! g1t as they are: this crate reads them ([`workflow`]), evaluates their
3+//! `${{ }}` expressions ([`expr`]), matches their branch and path filters
4+//! ([`filter`]), expands their matrices ([`matrix`]), and says which g1t
5+//! events are which GitHub events ([`events`]).
6+//!
7+//! It has no I/O, so the actions service (in a Worker) and the sandbox
8+//! (in a container) share it: the service decides what runs, the sandbox
9+//! runs the steps, and both read workflows and expressions the same way.
10+
11+pub mod cron;
12+pub mod events;
13+pub mod expr;
14+pub mod filter;
15+pub mod matrix;
16+pub mod workflow;
+177−0
1+//! `strategy.matrix`: every combination of its values, less `exclude`,
2+//! plus `include`, the way GitHub expands it.
3+
4+use serde_json::{Map, Value};
5+
6+/// The most jobs one matrix may make, as on GitHub.
7+pub const MAX_JOBS: usize = 256;
8+
9+/// One combination: the matrix's keys, in the file's order, and values.
10+pub type Combination = Map<String, Value>;
11+
12+/// Expands a matrix. `Ok(vec![])` means it made no jobs, which GitHub
13+/// treats as an error the caller reports.
14+pub fn expand(matrix: &Value) -> Result<Vec<Combination>, String> {
15+ let Value::Object(matrix) = matrix else {
16+ return Err("`strategy.matrix` is a mapping of names to lists of values.".to_owned());
17+ };
18+ let mut combinations: Vec<Combination> = vec![Map::new()];
19+ let mut dimensions = 0;
20+ for (key, values) in matrix {
21+ if key == "include" || key == "exclude" {
22+ continue;
23+ }
24+ let Value::Array(values) = values else {
25+ return Err(format!("`matrix.{key}` is a list of values."));
26+ };
27+ dimensions += 1;
28+ let mut next = Vec::with_capacity(combinations.len() * values.len());
29+ for combination in &combinations {
30+ for value in values {
31+ let mut extended = combination.clone();
32+ extended.insert(key.clone(), value.clone());
33+ next.push(extended);
34+ }
35+ }
36+ combinations = next;
37+ if combinations.len() > MAX_JOBS {
38+ return Err(format!("The matrix makes more than {MAX_JOBS} jobs."));
39+ }
40+ }
41+ if dimensions == 0 {
42+ combinations.clear();
43+ }
44+
45+ if let Some(exclude) = matrix.get("exclude") {
46+ let Value::Array(excludes) = exclude else {
47+ return Err("`matrix.exclude` is a list of combinations.".to_owned());
48+ };
49+ for exclude in excludes {
50+ let Value::Object(exclude) = exclude else {
51+ return Err("Each `matrix.exclude` entry is a mapping.".to_owned());
52+ };
53+ combinations.retain(|combination| !partial_match(combination, exclude));
54+ }
55+ }
56+
57+ if let Some(include) = matrix.get("include") {
58+ let Value::Array(includes) = include else {
59+ return Err("`matrix.include` is a list of combinations.".to_owned());
60+ };
61+ // The keys of the original matrix, whose values an include may not change.
62+ let originals: Vec<&String> = matrix.keys().filter(|key| *key != "include" && *key != "exclude").collect();
63+ let base_count = combinations.len();
64+ for include in includes {
65+ let Value::Object(include) = include else {
66+ return Err("Each `matrix.include` entry is a mapping.".to_owned());
67+ };
68+ let mut added = false;
69+ for combination in combinations.iter_mut().take(base_count) {
70+ let overwrites_original = include
71+ .iter()
72+ .any(|(key, value)| originals.contains(&key) && combination.get(key).is_some_and(|existing| existing != value));
73+ if !overwrites_original {
74+ for (key, value) in include {
75+ combination.insert(key.clone(), value.clone());
76+ }
77+ added = true;
78+ }
79+ }
80+ if !added {
81+ combinations.push(include.clone());
82+ }
83+ }
84+ }
85+ if combinations.len() > MAX_JOBS {
86+ return Err(format!("The matrix makes more than {MAX_JOBS} jobs."));
87+ }
88+ Ok(combinations)
89+}
90+
91+fn partial_match(combination: &Combination, pattern: &Map<String, Value>) -> bool {
92+ pattern.iter().all(|(key, value)| match (combination.get(key), value) {
93+ (Some(Value::Object(inner)), Value::Object(pattern)) => partial_match(inner, pattern),
94+ (Some(actual), expected) => actual == expected,
95+ (None, _) => false,
96+ })
97+}
98+
99+/// A job's name with its combination, as GitHub shows it:
100+/// `test (ubuntu-latest, 18)`. Objects in the combination are left out.
101+pub fn job_name(name: &str, combination: &Combination) -> String {
102+ let values: Vec<String> = combination
103+ .values()
104+ .filter_map(|value| match value {
105+ Value::String(text) => Some(text.clone()),
106+ Value::Number(number) => Some(number.to_string()),
107+ Value::Bool(flag) => Some(flag.to_string()),
108+ _ => None,
109+ })
110+ .collect();
111+ if values.is_empty() { name.to_owned() } else { format!("{name} ({})", values.join(", ")) }
112+}
113+
114+#[cfg(test)]
115+mod tests {
116+ use super::*;
117+ use serde_json::json;
118+
119+ fn names(combinations: &[Combination]) -> Vec<String> {
120+ combinations.iter().map(|c| job_name("test", c)).collect()
121+ }
122+
123+ #[test]
124+ fn products_in_file_order() {
125+ let jobs = expand(&json!({ "os": ["ubuntu-latest", "windows-latest"], "node": [18, 20] })).unwrap();
126+ assert_eq!(
127+ names(&jobs),
128+ ["test (ubuntu-latest, 18)", "test (ubuntu-latest, 20)", "test (windows-latest, 18)", "test (windows-latest, 20)"]
129+ );
130+ }
131+
132+ #[test]
133+ fn excludes_partial_matches() {
134+ let jobs = expand(&json!({
135+ "os": ["macos", "windows"], "version": [12, 14, 16], "environment": ["staging", "production"],
136+ "exclude": [{ "os": "macos", "version": 12, "environment": "production" }, { "os": "windows", "version": 16 }]
137+ }))
138+ .unwrap();
139+ assert_eq!(jobs.len(), 12 - 1 - 2);
140+ }
141+
142+ #[test]
143+ fn includes_extend_or_add_as_github_documents() {
144+ // GitHub's own example.
145+ let jobs = expand(&json!({
146+ "fruit": ["apple", "pear"], "animal": ["cat", "dog"],
147+ "include": [
148+ { "color": "green" },
149+ { "color": "pink", "animal": "cat" },
150+ { "fruit": "apple", "shape": "circle" },
151+ { "fruit": "banana" },
152+ { "fruit": "banana", "animal": "cat" }
153+ ]
154+ }))
155+ .unwrap();
156+ let expected = vec![
157+ json!({ "fruit": "apple", "animal": "cat", "color": "pink", "shape": "circle" }),
158+ json!({ "fruit": "apple", "animal": "dog", "color": "green", "shape": "circle" }),
159+ json!({ "fruit": "pear", "animal": "cat", "color": "pink" }),
160+ json!({ "fruit": "pear", "animal": "dog", "color": "green" }),
161+ json!({ "fruit": "banana" }),
162+ json!({ "fruit": "banana", "animal": "cat" }),
163+ ];
164+ let got: Vec<Value> = jobs.into_iter().map(Value::Object).collect();
165+ assert_eq!(got, expected);
166+ }
167+
168+ #[test]
169+ fn include_only_and_limits() {
170+ let jobs = expand(&json!({ "include": [{ "site": "a" }, { "site": "b" }] })).unwrap();
171+ assert_eq!(names(&jobs), ["test (a)", "test (b)"]);
172+ let big: Vec<u32> = (0..20).collect();
173+ assert!(expand(&json!({ "a": big, "b": big })).unwrap_err().contains("256"));
174+ assert!(expand(&json!({ "os": "linux" })).is_err());
175+ assert!(expand(&json!({})).unwrap().is_empty());
176+ }
177+}
+576−0
1+//! Reading a workflow file: its triggers, jobs and steps, and notes on
2+//! anything in it that runs differently on g1t, so moving a repository
3+//! from GitHub says plainly what to expect.
4+
5+use serde::{Deserialize, Serialize};
6+use serde_json::{Map, Value};
7+
8+use crate::filter::{Filter, Patterns};
9+
10+/// Where workflows live.
11+pub const FOLDER: &str = ".github/workflows";
12+
13+/// The events a workflow can name that g1t starts runs for.
14+pub const SUPPORTED_EVENTS: &[&str] = &[
15+ "push",
16+ "pull_request",
17+ "pull_request_target",
18+ "pull_request_review",
19+ "issues",
20+ "issue_comment",
21+ "schedule",
22+ "workflow_dispatch",
23+ "repository_dispatch",
24+ "workflow_call",
25+ "merge_group",
26+ "create",
27+ "delete",
28+];
29+
30+/// The `types` each event has when a workflow gives none, as on GitHub.
31+pub fn default_types(event: &str) -> &'static [&'static str] {
32+ match event {
33+ "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"],
34+ "merge_group" => &["checks_requested"],
35+ _ => &[],
36+ }
37+}
38+
39+/// How much a note matters.
40+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41+#[serde(rename_all = "snake_case")]
42+pub enum Severity {
43+ /// Runs, slightly differently.
44+ Info,
45+ /// Runs, but something in it does nothing or may not work.
46+ Warning,
47+ /// Does not run on g1t.
48+ Unsupported,
49+}
50+
51+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52+pub struct Note {
53+ pub severity: Severity,
54+ /// The job, if the note is about one.
55+ #[serde(skip_serializing_if = "Option::is_none")]
56+ pub job: Option<String>,
57+ pub message: String,
58+}
59+
60+/// One event a workflow is started by, with its filters.
61+#[derive(Clone, Debug, Default, PartialEq, Eq)]
62+pub struct Trigger {
63+ pub event: String,
64+ /// Activity types; empty means the event's defaults (or all).
65+ pub types: Vec<String>,
66+ pub branches: Filter,
67+ pub tags: Filter,
68+ pub paths: Filter,
69+ /// For `schedule`.
70+ pub crons: Vec<String>,
71+ /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
72+ pub inputs: Map<String, Value>,
73+}
74+
75+impl Trigger {
76+ /// Whether an activity type starts it.
77+ pub fn wants_type(&self, action: Option<&str>) -> bool {
78+ let Some(action) = action else { return true };
79+ if self.types.is_empty() {
80+ let defaults = default_types(&self.event);
81+ return defaults.is_empty() || defaults.contains(&action);
82+ }
83+ self.types.iter().any(|t| t == action)
84+ }
85+}
86+
87+#[derive(Clone, Debug, PartialEq)]
88+pub struct Step {
89+ pub id: Option<String>,
90+ pub name: Option<String>,
91+ pub condition: Option<String>,
92+ pub uses: Option<String>,
93+ pub run: Option<String>,
94+ /// The whole step as written, for the sandbox.
95+ pub raw: Value,
96+}
97+
98+impl Step {
99+ /// How the step is shown when it has no name.
100+ pub fn title(&self) -> String {
101+ if let Some(name) = &self.name {
102+ return name.clone();
103+ }
104+ if let Some(uses) = &self.uses {
105+ return format!("Run {uses}");
106+ }
107+ let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
108+ format!("Run {}", first.trim())
109+ }
110+}
111+
112+#[derive(Clone, Debug, PartialEq)]
113+pub struct Job {
114+ /// Its key under `jobs:`.
115+ pub id: String,
116+ pub name: Option<String>,
117+ pub needs: Vec<String>,
118+ pub condition: Option<String>,
119+ pub runs_on: Value,
120+ /// `strategy.matrix`, as written (it may be an expression).
121+ pub matrix: Option<Value>,
122+ pub fail_fast: bool,
123+ pub max_parallel: Option<u32>,
124+ /// A reusable workflow it calls (`uses:` on a job).
125+ pub uses: Option<String>,
126+ pub steps: Vec<Step>,
127+ /// The whole job as written, for the sandbox.
128+ pub raw: Value,
129+}
130+
131+#[derive(Clone, Debug, PartialEq)]
132+pub struct Workflow {
133+ pub name: Option<String>,
134+ pub run_name: Option<String>,
135+ pub triggers: Vec<Trigger>,
136+ pub env: Map<String, Value>,
137+ pub concurrency: Option<Concurrency>,
138+ pub jobs: Vec<Job>,
139+ pub notes: Vec<Note>,
140+ /// The whole workflow as written.
141+ pub raw: Value,
142+}
143+
144+#[derive(Clone, Debug, PartialEq, Eq)]
145+pub struct Concurrency {
146+ /// May hold an expression.
147+ pub group: String,
148+ pub cancel_in_progress: Value,
149+}
150+
151+impl Workflow {
152+ pub fn trigger(&self, event: &str) -> Option<&Trigger> {
153+ self.triggers.iter().find(|trigger| trigger.event == event)
154+ }
155+
156+ /// The name shown for it: its `name`, or its file's path.
157+ pub fn display_name(&self, path: &str) -> String {
158+ self.name.clone().unwrap_or_else(|| path.to_owned())
159+ }
160+
161+ /// The job ids in an order where each comes after the jobs it needs.
162+ pub fn job_order(&self) -> Vec<&str> {
163+ let mut ordered: Vec<&str> = Vec::new();
164+ while ordered.len() < self.jobs.len() {
165+ let before = ordered.len();
166+ for job in &self.jobs {
167+ if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
168+ ordered.push(&job.id);
169+ }
170+ }
171+ if ordered.len() == before {
172+ break;
173+ }
174+ }
175+ ordered
176+ }
177+}
178+
179+/// YAML to JSON, keeping the order of keys. Keys that are not strings
180+/// (`on: true` in YAML 1.1, numbers) become their text.
181+pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
182+ match value {
183+ serde_yaml::Value::Null => Value::Null,
184+ serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
185+ serde_yaml::Value::Number(number) => {
186+ if let Some(n) = number.as_i64() {
187+ Value::from(n)
188+ } else if let Some(n) = number.as_u64() {
189+ Value::from(n)
190+ } else {
191+ number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
192+ }
193+ }
194+ serde_yaml::Value::String(text) => Value::String(text.clone()),
195+ serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
196+ serde_yaml::Value::Mapping(map) => {
197+ let mut out = Map::new();
198+ for (key, value) in map {
199+ let key = match key {
200+ serde_yaml::Value::String(text) => text.clone(),
201+ serde_yaml::Value::Bool(flag) => flag.to_string(),
202+ serde_yaml::Value::Number(number) => number.to_string(),
203+ _ => continue,
204+ };
205+ out.insert(key, yaml_to_json(value));
206+ }
207+ Value::Object(out)
208+ }
209+ serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
210+ }
211+}
212+
213+fn texts(value: Option<&Value>) -> Vec<String> {
214+ match value {
215+ Some(Value::String(text)) => vec![text.clone()],
216+ Some(Value::Array(items)) => items
217+ .iter()
218+ .filter_map(|item| match item {
219+ Value::String(text) => Some(text.clone()),
220+ Value::Number(n) => Some(n.to_string()),
221+ _ => None,
222+ })
223+ .collect(),
224+ _ => Vec::new(),
225+ }
226+}
227+
228+fn text(value: Option<&Value>) -> Option<String> {
229+ match value? {
230+ Value::String(text) => Some(text.clone()),
231+ Value::Number(n) => Some(n.to_string()),
232+ Value::Bool(flag) => Some(flag.to_string()),
233+ _ => None,
234+ }
235+}
236+
237+fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
238+ let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
239+ Filter { only: list(only), ignore: list(ignore) }
240+}
241+
242+fn trigger(event: &str, spec: &Value) -> Trigger {
243+ let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
244+ match spec {
245+ Value::Object(spec) => {
246+ trigger.types = texts(spec.get("types"));
247+ trigger.branches = filter(spec, "branches", "branches-ignore");
248+ trigger.tags = filter(spec, "tags", "tags-ignore");
249+ trigger.paths = filter(spec, "paths", "paths-ignore");
250+ if let Some(Value::Object(inputs)) = spec.get("inputs") {
251+ trigger.inputs = inputs.clone();
252+ }
253+ }
254+ Value::Array(entries) if event == "schedule" => {
255+ trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
256+ }
257+ _ => {}
258+ }
259+ trigger
260+}
261+
262+/// Reads a workflow. `Err` is what is wrong with the file, for the person
263+/// who wrote it; what reads but runs differently is in `notes`.
264+pub fn parse(source: &str) -> Result<Workflow, String> {
265+ let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
266+ let raw = yaml_to_json(&yaml);
267+ let Value::Object(root) = &raw else {
268+ return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
269+ };
270+ let mut notes = Vec::new();
271+ let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
272+
273+ // `on`, in any of its three shapes. YAML 1.1 readers turn `on` into
274+ // `true`; this reader keeps it, and accepts both.
275+ let on = root.get("on").or_else(|| root.get("true")).ok_or("`on` is missing: say which events start the workflow.")?;
276+ let mut triggers = Vec::new();
277+ match on {
278+ Value::String(event) => triggers.push(trigger(event, &Value::Null)),
279+ Value::Array(events) => {
280+ for event in events {
281+ let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
282+ triggers.push(trigger(event, &Value::Null));
283+ }
284+ }
285+ Value::Object(events) => {
286+ for (event, spec) in events {
287+ triggers.push(trigger(event, spec));
288+ }
289+ }
290+ _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
291+ }
292+ for trigger in &triggers {
293+ if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
294+ note(
295+ Severity::Unsupported,
296+ None,
297+ format!("g1t has no `{}` event, so that trigger never starts it.", trigger.event),
298+ );
299+ }
300+ if trigger.event == "pull_request_target" {
301+ note(
302+ Severity::Info,
303+ None,
304+ "`pull_request_target` runs like `pull_request`, on the pull request's head, with the repository's secrets.".to_owned(),
305+ );
306+ }
307+ if trigger.event == "workflow_call" && triggers.len() == 1 {
308+ note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
309+ }
310+ }
311+
312+ let env = match root.get("env") {
313+ Some(Value::Object(env)) => env.clone(),
314+ _ => Map::new(),
315+ };
316+ let concurrency = match root.get("concurrency") {
317+ Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
318+ Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
319+ group,
320+ cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
321+ }),
322+ _ => None,
323+ };
324+
325+ let Some(Value::Object(job_specs)) = root.get("jobs") else {
326+ return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
327+ };
328+ if job_specs.is_empty() {
329+ return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
330+ }
331+ let mut jobs = Vec::new();
332+ for (id, spec) in job_specs {
333+ let Value::Object(spec) = spec else {
334+ return Err(format!("Job `{id}` is a mapping."));
335+ };
336+ let uses = text(spec.get("uses"));
337+ let steps_raw = match spec.get("steps") {
338+ Some(Value::Array(steps)) => steps.clone(),
339+ None if uses.is_some() => Vec::new(),
340+ None => return Err(format!("Job `{id}` has no `steps`.")),
341+ Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
342+ };
343+ let mut steps = Vec::new();
344+ for (index, step) in steps_raw.iter().enumerate() {
345+ let Value::Object(fields) = step else {
346+ return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
347+ };
348+ let step = Step {
349+ id: text(fields.get("id")),
350+ name: text(fields.get("name")),
351+ condition: text(fields.get("if")),
352+ uses: text(fields.get("uses")),
353+ run: text(fields.get("run")),
354+ raw: step.clone(),
355+ };
356+ match (&step.uses, &step.run) {
357+ (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
358+ (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
359+ _ => {}
360+ }
361+ if let Some(uses) = &step.uses
362+ && let Some((severity, message)) = action_note(uses)
363+ {
364+ note(severity, Some(id), message);
365+ }
366+ if let Some(shell) = text(fields.get("shell"))
367+ && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
368+ {
369+ note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
370+ }
371+ steps.push(step);
372+ }
373+ let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
374+ for label in texts(Some(&runs_on)).iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default().iter()) {
375+ let lower = label.to_ascii_lowercase();
376+ if lower.contains("windows") || lower.contains("macos") {
377+ note(
378+ Severity::Unsupported,
379+ Some(id),
380+ format!("`runs-on: {label}`: g1t runs jobs on Linux only, so this job fails."),
381+ );
382+ } else if lower == "self-hosted" {
383+ note(Severity::Info, Some(id), "`self-hosted`: g1t runs it on its own Linux runner.".to_owned());
384+ }
385+ }
386+ if spec.contains_key("services") {
387+ note(Severity::Unsupported, Some(id), "`services` containers (such as a database) are not started on g1t yet.".to_owned());
388+ }
389+ if spec.contains_key("container") {
390+ note(Severity::Warning, Some(id), "`container`: steps run on g1t's runner image instead of that container.".to_owned());
391+ }
392+ if spec.contains_key("environment") {
393+ note(Severity::Info, Some(id), "`environment`: protection rules are not enforced on g1t yet; the job runs with the repository's secrets.".to_owned());
394+ }
395+ let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
396+ Some(Value::Object(strategy)) => (
397+ strategy.get("matrix").cloned(),
398+ strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
399+ strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
400+ ),
401+ _ => (None, true, None),
402+ };
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());
405+ }
406+ jobs.push(Job {
407+ id: id.clone(),
408+ name: text(spec.get("name")),
409+ needs: texts(spec.get("needs")),
410+ condition: text(spec.get("if")),
411+ runs_on,
412+ matrix,
413+ fail_fast,
414+ max_parallel,
415+ uses,
416+ steps,
417+ raw: Value::Object(spec.clone()),
418+ });
419+ }
420+ for job in &jobs {
421+ for need in &job.needs {
422+ if !jobs.iter().any(|other| &other.id == need) {
423+ return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
424+ }
425+ }
426+ }
427+ let workflow = Workflow {
428+ name: text(root.get("name")),
429+ run_name: text(root.get("run-name")),
430+ triggers,
431+ env,
432+ concurrency,
433+ jobs,
434+ notes,
435+ raw,
436+ };
437+ if workflow.job_order().len() < workflow.jobs.len() {
438+ return Err("The jobs' `needs` go round in a circle.".to_owned());
439+ }
440+ Ok(workflow)
441+}
442+
443+/// What to say about an action g1t runs differently, if anything.
444+fn action_note(uses: &str) -> Option<(Severity, String)> {
445+ if uses.starts_with("docker://") {
446+ return Some((Severity::Unsupported, format!("`{uses}`: Docker actions do not run on g1t yet.")));
447+ }
448+ let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
449+ match name.as_str() {
450+ "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
451+ "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
452+ Severity::Warning,
453+ format!("`{name}`: g1t has no cache yet, so it always misses and the job does the work again."),
454+ )),
455+ "actions/upload-artifact" | "actions/download-artifact" => Some((
456+ Severity::Warning,
457+ format!("`{name}`: artifacts are kept for the run on g1t, and passed between its jobs."),
458+ )),
459+ _ => None,
460+ }
461+}
462+
463+#[cfg(test)]
464+mod tests {
465+ use super::*;
466+
467+ const CI: &str = r#"
468+name: CI
469+on:
470+ push:
471+ branches: [main]
472+ paths-ignore: ["docs/**"]
473+ pull_request:
474+ workflow_dispatch:
475+ inputs:
476+ debug:
477+ type: boolean
478+ default: false
479+ schedule:
480+ - cron: "0 3 * * *"
481+concurrency:
482+ group: ci-${{ github.ref }}
483+ cancel-in-progress: true
484+env:
485+ CARGO_TERM_COLOR: always
486+jobs:
487+ test:
488+ runs-on: ${{ matrix.os }}
489+ strategy:
490+ matrix:
491+ os: [ubuntu-latest, windows-latest]
492+ node: [18, 20]
493+ steps:
494+ - uses: actions/checkout@v4
495+ - uses: actions/setup-node@v4
496+ with:
497+ node-version: ${{ matrix.node }}
498+ - run: npm ci
499+ - name: Test
500+ run: npm test
501+ deploy:
502+ needs: test
503+ if: github.ref == 'refs/heads/main'
504+ runs-on: ubuntu-latest
505+ steps:
506+ - run: echo deploy
507+"#;
508+
509+ #[test]
510+ fn a_whole_workflow_reads() {
511+ let workflow = parse(CI).unwrap();
512+ assert_eq!(workflow.name.as_deref(), Some("CI"));
513+ assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
514+ let push = workflow.trigger("push").unwrap();
515+ assert!(push.branches.allows("main"));
516+ assert!(!push.branches.allows("dev"));
517+ assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
518+ assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
519+ assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
520+ assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
521+ assert_eq!(workflow.jobs.len(), 2);
522+ assert_eq!(workflow.jobs[1].needs, ["test"]);
523+ assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v4");
524+ assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
525+ assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
526+ assert_eq!(workflow.job_order(), ["test", "deploy"]);
527+ assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
528+ }
529+
530+ #[test]
531+ fn short_forms_of_on() {
532+ let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
533+ assert_eq!(one.triggers[0].event, "push");
534+ let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
535+ assert_eq!(list.triggers.len(), 2);
536+ let pr = list.trigger("pull_request").unwrap();
537+ assert!(pr.wants_type(Some("opened")));
538+ assert!(pr.wants_type(Some("synchronize")));
539+ assert!(!pr.wants_type(Some("closed")));
540+ let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
541+ assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
542+ assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
543+ }
544+
545+ #[test]
546+ fn notes_say_what_runs_differently() {
547+ let workflow = parse(
548+ "on: [push, release]\njobs:\n win:\n runs-on: windows-latest\n services:\n db: { image: postgres }\n steps:\n - uses: actions/cache@v4\n - uses: docker://alpine\n - run: dir\n shell: pwsh",
549+ )
550+ .unwrap();
551+ let unsupported: Vec<&str> =
552+ workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
553+ assert!(unsupported.iter().any(|m| m.contains("`release`")));
554+ assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
555+ assert!(unsupported.iter().any(|m| m.contains("services")));
556+ assert!(unsupported.iter().any(|m| m.contains("docker://alpine")));
557+ assert!(unsupported.iter().any(|m| m.contains("pwsh")));
558+ assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/cache")));
559+ }
560+
561+ #[test]
562+ fn mistakes_are_explained() {
563+ let problem = |yaml: &str| parse(yaml).unwrap_err();
564+ assert!(problem("jobs: {}").contains("`on` is missing"));
565+ assert!(problem("on: push").contains("`jobs` is missing"));
566+ assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
567+ assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
568+ assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
569+ assert!(
570+ problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]\n b:\n needs: a\n runs-on: x\n steps: [{ run: x }]")
571+ .contains("circle")
572+ );
573+ assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
574+ assert!(problem(": : :").contains("not valid YAML"));
575+ }
576+}
+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+}
+4−1
3535 pub pull_id: String,
3636 }
3737
38−/// One branch moved by a push. `after` is the commit it points to now.
38+/// One branch or tag moved by a push. `after` is the commit it points to now.
3939 #[derive(Debug, Serialize)]
4040 #[serde(rename_all = "camelCase")]
4141 pub struct GitPush {
4343 /// The full ref, such as `refs/heads/main`.
4444 #[serde(rename = "ref")]
4545 pub git_ref: String,
46+ /// Where it pointed before; absent for a new branch or tag.
47+ #[serde(skip_serializing_if = "Option::is_none")]
48+ pub before: Option<String>,
4649 pub after: String,
4750 /// Whether the ref is the repository's default branch.
4851 pub default_branch: bool,
+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 },
+44−13
127127
128128 const ZERO_ID: &str = "0000000000000000000000000000000000000000";
129129 const HEADS: &str = "refs/heads/";
130+const TAGS: &str = "refs/tags/";
130131
131132 /// One ref a push asks to change.
132133 struct Command {
219220 })
220221 }
221222
222−/// The branches a push asks to move, as `(branch, new commit)`, read from
223−/// the commands at the start of a receive-pack request. Deletions and refs
224−/// that are not branches are left out.
225−fn pushed_branches(body: &[u8]) -> Vec<(String, String)> {
223+/// A branch or tag a push asks to move.
224+#[derive(Debug, PartialEq, Eq)]
225+pub struct Pushed {
226+ /// The full ref: `refs/heads/main`, `refs/tags/v1`.
227+ pub git_ref: String,
228+ /// Where it pointed before; `None` for a new ref.
229+ pub before: Option<String>,
230+ pub after: String,
231+}
232+
233+impl Pushed {
234+ pub fn branch(&self) -> Option<&str> {
235+ self.git_ref.strip_prefix(HEADS)
236+ }
237+}
238+
239+/// The branches and tags a push asks to move, read from the commands at the
240+/// start of a receive-pack request. Deletions and other refs are left out.
241+fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
226242 commands(body)
227243 .0
228244 .into_iter()
229245 .filter(|command| command.new != ZERO_ID)
230− .filter_map(|Command { new, name, .. }| {
231− name.strip_prefix(HEADS)
232− .map(|branch| (branch.to_owned(), new))
246+ .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
247+ .map(|Command { old, new, name }| Pushed {
248+ git_ref: name,
249+ before: (old != ZERO_ID).then_some(old),
250+ after: new,
233251 })
234252 .collect()
235253 }
237255 /// The git store's answer, and what the request asked it to change.
238256 pub struct Forwarded {
239257 pub response: Response,
240− /// For a push: the branches it asks to move and the commits to move
241− /// them to. Whether each moved is for the caller to confirm.
242− pub pushed: Vec<(String, String)>,
258+ /// For a push: the branches and tags it asks to move, and the commits
259+ /// to move them to. Whether each moved is for the caller to confirm.
260+ pub pushed: Vec<Pushed>,
243261 }
244262
245263 /// What became of a git request.
298316
299317 #[cfg(test)]
300318 mod tests {
301− use super::{ZERO_ID, pushed_branches, refusal};
319+ use super::{Pushed, ZERO_ID, pushed_branches, refusal};
302320
303321 fn pkt(payload: &str) -> Vec<u8> {
304322 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
322340 assert_eq!(
323341 pushed_branches(&body),
324342 [
325− ("main".to_owned(), new.to_owned()),
326− ("feature/x".to_owned(), new.to_owned()),
343+ Pushed {
344+ git_ref: "refs/heads/main".to_owned(),
345+ before: Some(old.to_owned()),
346+ after: new.to_owned()
347+ },
348+ Pushed {
349+ git_ref: "refs/heads/feature/x".to_owned(),
350+ before: None,
351+ after: new.to_owned()
352+ },
353+ Pushed {
354+ git_ref: "refs/tags/v1".to_owned(),
355+ before: None,
356+ after: new.to_owned()
357+ },
327358 ]
328359 );
329360 }
+43−12
339339 })
340340 .await?;
341341 if let Some(head) = pushed {
342− self.publish_push(&repo, &repo.default_branch, &head, None)
342+ self.publish_push(
343+ &repo,
344+ &format!("refs/heads/{}", repo.default_branch),
345+ None,
346+ &head,
347+ None,
348+ )
343349 .await?;
344350 }
345351 Ok(Outcome::Ok(repo))
719725 format!("{branch} could not be updated: {reason}"),
720726 ));
721727 }
722− self.publish_push(&target, branch, &new, Some(a.actor.id))
728+ self.publish_push(
729+ &target,
730+ &format!("refs/heads/{branch}"),
731+ old.as_deref(),
732+ &new,
733+ Some(a.actor.id),
734+ )
723735 .await?;
724736 Ok(Outcome::Ok(Landed {
725737 commit: new,
791803 }))
792804 }
793805
794− /// Reports that `branch` of `repo` now points to `after`.
806+ /// Reports that `git_ref` of `repo` (a full ref) now points to `after`.
795807 async fn publish_push(
796808 &self,
797809 repo: &Repo,
798− branch: &str,
810+ git_ref: &str,
811+ before: Option<&str>,
799812 after: &str,
800813 actor: Option<String>,
801814 ) -> Result<()> {
806819 actor,
807820 data: GitPush {
808821 repo_id: repo.id.clone(),
809− git_ref: format!("refs/heads/{branch}"),
822+ git_ref: git_ref.to_owned(),
823+ before: before.map(str::to_owned),
810824 after: after.to_owned(),
811− default_branch: branch == repo.default_branch,
825+ default_branch: git_ref.strip_prefix("refs/heads/")
826+ == Some(repo.default_branch.as_str()),
812827 },
813828 })
814829 .await
850865 if accepted && let Some(repo) = self.registry.by_path(&git.path).await? {
851866 let stored = self.store.open(&store_key(&repo)).await?;
852867 let actor = viewer.map(|user: User| user.id);
853− for (branch, pushed) in &forwarded.pushed {
868+ for pushed in &forwarded.pushed {
854869 // The store can refuse one ref and accept another, so each
855− // is checked against where the branch actually is.
856− let head = stored.log(branch, 1).await?;
857− if head.first().is_some_and(|commit| commit.hash == *pushed) {
858− self.publish_push(&repo, branch, pushed, actor.clone())
859− .await?;
870+ // branch is checked against where it actually is. A tag the
871+ // store cannot read back is taken as pushed.
872+ let moved = match pushed.branch() {
873+ Some(branch) => stored
874+ .log(branch, 1)
875+ .await?
876+ .first()
877+ .is_some_and(|commit| commit.hash == pushed.after),
878+ None => stored.log(&pushed.git_ref, 1).await.map_or(true, |head| {
879+ head.first().is_none_or(|commit| commit.hash == pushed.after)
880+ }),
881+ };
882+ if moved {
883+ self.publish_push(
884+ &repo,
885+ &pushed.git_ref,
886+ pushed.before.as_deref(),
887+ &pushed.after,
888+ actor.clone(),
889+ )
890+ .await?;
860891 }
861892 }
862893 }
+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?),
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.