Skip to content
1,316 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

A flat project menu with Code first, views as tabs, and Stripe for every owner1//! 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
5use g1t_actions::events::{RunInfo, github_events};
6use g1t_actions::workflow::{self, Trigger, Workflow};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7use g1t_contracts::access::{self, Capability};
Merge branch 'worktree-agent-a3abfcce648e87dca'8use g1t_contracts::actions::{DispatchArgs, RepositoryDispatchArgs, WorkflowRun};
9use g1t_contracts::events::{Event, caused_by_job};
A flat project menu with Code first, views as tabs, and Stripe for every owner10use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
11use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, Repo, RepoPath};
12use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs};
13use g1t_contracts::{FailureCode, Outcome, User, new_id};
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails14use g1t_contracts::time::rfc3339;
A flat project menu with Code first, views as tabs, and Stripe for every owner15use g1t_kit::now_ms;
16use serde_json::{Map, Value, json};
17use worker::Result;
18
19use crate::plan::NewRun;
20use crate::sync::{Read, WorkflowRow};
21use crate::{API, Actions, SITE, check, fail, payload};
22
23/// What an event is about, worked out once for every workflow it starts.
24struct Subject {
25 /// Where the workflow files are read, and at which commit.
26 source: RepoPath,
27 source_ref: Option<String>,
28 git_ref: String,
29 sha: String,
30 head_ref: Option<String>,
31 base_ref: Option<String>,
32 pull: Option<u32>,
33 /// The branch or tag for `branches`/`tags` filters; for pull requests,
34 /// the branch they merge into.
35 filter_ref: String,
36 /// The files it changes, for `paths` filters; `None` until needed.
37 paths: Option<Vec<String>>,
38 /// For a push, what to compare to find the files.
39 compare: Option<(Option<String>, String)>,
40 payload: Value,
41 title: String,
42 trusted: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'43 /// Why its runs wait for approval first: a pull request from outside,
44 /// by the repository's approval policy (protection.rs).
45 approval: Option<String>,
A flat project menu with Code first, views as tabs, and Stripe for every owner46}
47
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts48/// Whether a deployment's `ref` is a commit's full hash rather than a
49/// branch or tag.
50fn is_commit(name: &str) -> bool {
51 name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit())
52}
53
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights54/// Whether whoever a pull request is for is trusted without asking
55/// identity: g1t's agent in work nobody asked it for, or someone whose
56/// role here is known to allow pushing.
57fn trusted_outright(owner: &User, repo: &Repo) -> bool {
58 owner.id == AGENT_ID || access::can(Some(owner), repo, Capability::Push)
59}
60
A flat project menu with Code first, views as tabs, and Stripe for every owner61impl Actions {
62 async fn username(&self, id: Option<&str>) -> Result<Option<String>> {
63 let Some(id) = id else { return Ok(None) };
64 if id == AGENT_ID {
65 return Ok(Some(AGENT_NAME.to_owned()));
66 }
67 let names: std::collections::HashMap<String, String> =
68 g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.to_owned()] }).await?;
69 Ok(names.get(id).cloned())
70 }
71
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights72 /// Whether whoever a pull request is for (Pull::owner: whoever asked
73 /// g1t for it, or its author) could push to the repository, so its
74 /// runs get the secrets and a token. Anyone else's, a reader's included
75 /// (who may open one on a private repository too), runs without them.
76 /// A change g1t made for someone is trusted as they are.
77 async fn insider(&self, owner: &User, repo: &Repo, ws: &User) -> Result<bool> {
78 if trusted_outright(owner, repo) {
A flat project menu with Code first, views as tabs, and Stripe for every owner79 return Ok(true);
80 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look81 // Stored authors carry no memberships or grants: ask identity, as
82 // the workspace (which may see anyone's permission).
83 let permission: Outcome<access::PermissionInfo> = g1t_kit::call(
A flat project menu with Code first, views as tabs, and Stripe for every owner84 &self.identity,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look85 "collaborator_permission",
86 &access::CollaboratorPermissionArgs {
87 viewer: Some(ws.clone()),
88 path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights89 username: owner.username.clone(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look90 },
A flat project menu with Code first, views as tabs, and Stripe for every owner91 )
92 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look93 Ok(permission
94 .into_result()
95 .ok()
96 .and_then(|info| info.role)
97 .is_some_and(|role| access::allows(role, Capability::Push)))
A flat project menu with Code first, views as tabs, and Stripe for every owner98 }
99
100 async fn commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<Vec<Commit>> {
101 let log: Outcome<Vec<Commit>> = g1t_kit::call(
102 &self.repos,
103 "log",
104 &LogArgs {
105 path: RepoPath {
106 namespace: repo.namespace.clone(),
107 name: repo.name.clone(),
108 },
109 viewer: Some(actor.clone()),
110 git_ref: Some(after.to_owned()),
111 limit: 20,
112 },
113 )
114 .await?;
115 let mut commits: Vec<Commit> = log.into_result().unwrap_or_default();
116 if let Some(before) = before
117 && let Some(at) = commits.iter().position(|commit| commit.hash == before)
118 {
119 commits.truncate(at);
120 }
121 // GitHub lists them oldest first, with the head commit last.
122 commits.reverse();
123 Ok(commits)
124 }
125
What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so.126 /// The message of the commit `sha` in `path`, when it can be read.
127 async fn head_message(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<String>> {
128 let log: Outcome<Vec<Commit>> = g1t_kit::call(
129 &self.repos,
130 "log",
131 &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 },
132 )
133 .await?;
134 Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next()).map(|commit| commit.message))
135 }
136
A flat project menu with Code first, views as tabs, and Stripe for every owner137 async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> {
138 let compared: Outcome<Comparison> = g1t_kit::call(
139 &self.repos,
140 "compare",
141 &CompareArgs {
142 repo_id: repo.id.clone(),
143 viewer: Some(actor.clone()),
144 base,
145 head: Some(head),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar146 base_branch: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner147 },
148 )
149 .await?;
150 Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| f.path).collect()).unwrap_or_default())
151 }
152
153 async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
154 g1t_kit::call(
155 &self.repos,
156 "head",
157 &g1t_contracts::repos::HeadArgs {
158 repo_id: repo.id.clone(),
159 branch: repo.default_branch.clone(),
160 },
161 )
162 .await
163 }
164
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts165 /// Whether `name` is one of the repository's branches.
166 async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> {
167 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
168 &self.repos,
169 "branches",
170 &g1t_contracts::repos::BranchesArgs {
171 path: Self::repo_path(repo),
172 viewer: Some(ws.clone()),
173 },
174 )
175 .await?;
176 Ok(branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == name))
177 }
178
A flat project menu with Code first, views as tabs, and Stripe for every owner179 fn repo_path(repo: &Repo) -> RepoPath {
180 RepoPath {
181 namespace: repo.namespace.clone(),
182 name: repo.name.clone(),
183 }
184 }
185
186 /// The subject of an event of `kind`, as GitHub's `event_name`.
187 async fn subject(&self, event: &Event, event_name: &str, action: Option<&str>, repo: &Repo, ws: &User, sender: &str) -> Result<Option<Subject>> {
188 let path = Self::repo_path(repo);
189 let data = &event.data;
190 let on_default = |sha: String, payload: Value, title: String, pull: Option<u32>| Subject {
191 source: path.clone(),
192 source_ref: None,
193 git_ref: format!("refs/heads/{}", repo.default_branch),
194 sha,
195 head_ref: None,
196 base_ref: None,
197 pull,
198 filter_ref: format!("refs/heads/{}", repo.default_branch),
199 paths: None,
200 compare: None,
201 payload,
202 title,
203 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'204 approval: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner205 };
206 let view = |number: u32| ViewArgs {
207 repo: path.clone(),
208 number,
209 viewer: Some(ws.clone()),
210 after_seq: 0,
211 };
212 Ok(match event_name {
213 "push" => {
214 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
215 return Ok(None);
216 };
217 // The merge queue's states run merge_group workflows, not push ones.
218 if git_ref.starts_with("refs/heads/g1t-queue/") {
219 return Ok(None);
220 }
221 let before = data["before"].as_str();
222 let commits = self.commits(repo, ws, after, before).await?;
223 let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
224 let mut payload = payload::push(repo, git_ref, before, after, &commits, sender);
225 if let Some(head) = commits.last() {
226 payload["head_commit"] = payload::commit(repo, head);
227 }
228 Some(Subject {
229 source: path.clone(),
230 source_ref: Some(after.to_owned()),
231 git_ref: git_ref.to_owned(),
232 sha: after.to_owned(),
233 head_ref: None,
234 base_ref: None,
235 pull: None,
236 filter_ref: git_ref.to_owned(),
237 paths: None,
238 compare: Some((before.map(str::to_owned), after.to_owned())),
239 payload,
240 title,
241 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'242 approval: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner243 })
244 }
Merge branch 'worktree-agent-a3abfcce648e87dca'245 // A branch or tag was made: its own commit, as on GitHub.
246 "create" => {
247 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
248 return Ok(None);
249 };
250 if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() {
251 return Ok(None);
252 }
253 let (ref_type, name) = match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) {
254 (Some(branch), _) => ("branch", branch),
255 (_, Some(tag)) => ("tag", tag),
256 _ => return Ok(None),
257 };
258 let payload = json!({
259 "ref": name,
260 "ref_type": ref_type,
261 "master_branch": repo.default_branch,
262 "description": repo.description,
263 "pusher_type": "user",
264 "repository": payload::repository(repo),
265 "sender": payload::user(sender),
266 });
267 Some(Subject {
268 source: path.clone(),
269 source_ref: Some(after.to_owned()),
270 git_ref: git_ref.to_owned(),
271 sha: after.to_owned(),
272 head_ref: None,
273 base_ref: None,
274 pull: None,
275 filter_ref: git_ref.to_owned(),
276 paths: None,
277 compare: None,
278 payload,
279 title: format!("Created {ref_type} {name}"),
280 trusted: true,
281 approval: None,
282 })
283 }
A flat project menu with Code first, views as tabs, and Stripe for every owner284 "pull_request" | "pull_request_target" | "pull_request_review" => {
285 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
286 let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
287 let Outcome::Ok(detail) = detail else { return Ok(None) };
288 let pull = &detail.pull;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar289 let base_ref = pull.base_branch(&repo.default_branch).to_owned();
A flat project menu with Code first, views as tabs, and Stripe for every owner290 let mut payload = json!({
291 "action": action,
292 "number": pull.number,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar293 "pull_request": payload::pull(repo, pull),
A flat project menu with Code first, views as tabs, and Stripe for every owner294 "repository": payload::repository(repo),
295 "sender": payload::user(sender),
296 });
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar297 payload::changed(&mut payload, data);
A flat project menu with Code first, views as tabs, and Stripe for every owner298 if event_name == "pull_request_review" {
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar299 let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory);
A flat project menu with Code first, views as tabs, and Stripe for every owner300 payload["review"] = json!({
What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so.301 "state": review.and_then(|r| r.verdict).map(payload::review_state),
A flat project menu with Code first, views as tabs, and Stripe for every owner302 "body": review.map(|r| r.body.clone()),
303 "user": review.map(|r| payload::user(&r.author.username)),
304 });
305 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights306 let trusted = self.insider(pull.owner(), repo, ws).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'307 // A pull request from outside may wait for approval before
308 // its head's code runs. `pull_request_target` runs the
309 // base's code, and a merged one's run the commit it landed
310 // as, so neither waits.
311 let approval = if event_name != "pull_request_target" && action != Some("closed") {
312 self.approval_needed(repo, pull.owner(), ws).await?
313 } else {
314 None
315 };
A flat project menu with Code first, views as tabs, and Stripe for every owner316 let head_ref = payload::head_ref(pull);
317 if event_name == "pull_request_target" {
318 // In the base's context: its workflows, its head.
319 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
320 let mut subject = on_default(sha, payload, pull.title.clone(), Some(pull.number));
321 subject.head_ref = Some(head_ref);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar322 subject.base_ref = Some(base_ref.clone());
323 subject.filter_ref = format!("refs/heads/{base_ref}");
A flat project menu with Code first, views as tabs, and Stripe for every owner324 subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect());
325 return Ok(Some(subject));
326 }
327 // A merged pull request's run is on the commit it landed as,
328 // in the repository; otherwise on its head, where that is.
329 let landed = match (action, data["commit"].as_str()) {
330 (Some("closed"), Some(commit)) => Some(commit.to_owned()),
331 _ => None,
332 };
333 let sha = match (&landed, data["commit"].as_str(), &pull.head_commit) {
334 (Some(commit), _, _) => commit.clone(),
335 (None, Some(commit), _) => commit.to_owned(),
336 (None, None, Some(head)) => head.clone(),
337 (None, None, None) => return Ok(None),
338 };
339 let source = match landed {
340 Some(_) => path.clone(),
341 None => pull.fork.clone().unwrap_or_else(|| path.clone()),
342 };
343 Some(Subject {
344 source,
345 source_ref: Some(sha.clone()),
346 git_ref: format!("refs/pull/{}/merge", pull.number),
347 sha,
348 head_ref: Some(head_ref),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar349 base_ref: Some(base_ref.clone()),
A flat project menu with Code first, views as tabs, and Stripe for every owner350 pull: Some(pull.number),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar351 // `branches` filters on pull requests name the base.
352 filter_ref: format!("refs/heads/{base_ref}"),
A flat project menu with Code first, views as tabs, and Stripe for every owner353 paths: Some(pull.files.iter().map(|f| f.path.clone()).collect()),
354 compare: None,
355 payload,
356 title: pull.title.clone(),
357 trusted,
Merge branch 'worktree-agent-a3abfcce648e87dca'358 approval,
A flat project menu with Code first, views as tabs, and Stripe for every owner359 })
360 }
361 "workflow_run" => {
362 // A run of a workflow_run workflow does not start another,
363 // so two such workflows cannot set each other off.
364 if data["event"].as_str() == Some("workflow_run") {
365 return Ok(None);
366 }
367 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
368 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
369 let name = data["workflow"].as_str().unwrap_or_default();
370 let payload = json!({
371 "action": "completed",
372 "workflow_run": {
373 "id": data["runId"],
374 "name": name,
375 "path": data["path"],
376 "event": data["event"],
377 "status": "completed",
378 "conclusion": data["conclusion"],
379 "head_sha": data["sha"],
380 "head_branch": head_branch,
381 "run_number": data["number"],
382 "html_url": format!("{SITE}/{}/{}/actions/runs/{}", repo.namespace, repo.name, data["runId"].as_str().unwrap_or_default()),
383 "pull_requests": data["pull"].as_u64().map(|n| vec![json!({ "number": n })]).unwrap_or_default(),
384 },
385 "workflow": { "name": name, "path": data["path"] },
386 "repository": payload::repository(repo),
387 "sender": payload::user(sender),
388 });
389 let mut subject = on_default(sha, payload, format!("After {name}"), None);
390 // Branch filters apply to the branch the followed run was on.
391 subject.filter_ref = format!("refs/heads/{head_branch}");
392 Some(subject)
393 }
394 "issues" | "issue_comment" => {
395 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
396 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
397 let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
398 let (issue_json, comments, title, on_pull) = match issue {
399 Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false),
400 Outcome::Fail(_) => {
401 let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
402 let Outcome::Ok(detail) = pull else { return Ok(None) };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar403 (payload::pull_as_issue(repo, &detail.pull), detail.comments, detail.pull.title.clone(), true)
A flat project menu with Code first, views as tabs, and Stripe for every owner404 }
405 };
406 let mut payload = json!({
407 "action": action,
408 "issue": issue_json,
409 "repository": payload::repository(repo),
410 "sender": payload::user(sender),
411 });
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar412 payload::changed(&mut payload, data);
A flat project menu with Code first, views as tabs, and Stripe for every owner413 if event_name == "issue_comment" {
414 let comment_id = data["commentId"].as_str();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts415 if action == Some("deleted") {
416 // Gone by now: as the event kept it.
417 match data.get("comment").filter(|kept| kept.is_object()) {
418 Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, on_pull),
419 None => return Ok(None),
420 }
421 } else {
422 let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id);
423 // A new comment is the newest; an edited one must be found.
424 let comment = if action == Some("created") { comment.or(comments.last()) } else { comment };
425 match comment {
426 Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
427 None => return Ok(None),
428 }
429 }
430 // `edited`: what the body was before.
431 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
432 payload["changes"] = changes.clone();
A flat project menu with Code first, views as tabs, and Stripe for every owner433 }
434 }
435 Some(on_default(sha, payload, title, on_pull.then_some(number)))
436 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts437 // A release: on its tag, at the commit the tag named.
438 "release" => {
439 let release = &data["release"];
440 let Some(tag) = data["tagName"].as_str().or_else(|| release["tagName"].as_str()) else { return Ok(None) };
441 let sha = match release["target"].as_str().filter(|target| !target.is_empty()) {
442 Some(target) => target.to_owned(),
443 None => match self.default_head(repo).await? {
444 Some(head) => head,
445 None => return Ok(None),
446 },
447 };
448 let git_ref = format!("refs/tags/{tag}");
449 let mut payload = json!({
450 "action": action,
451 "release": payload::release(repo, release),
452 "repository": payload::repository(repo),
453 "sender": payload::user(sender),
454 });
455 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
456 payload["changes"] = changes.clone();
457 }
458 let name = release["name"].as_str().filter(|name| !name.is_empty()).unwrap_or(tag);
459 Some(Subject {
460 source: path.clone(),
461 source_ref: Some(sha.clone()),
462 git_ref: git_ref.clone(),
463 sha,
464 head_ref: None,
465 base_ref: None,
466 pull: None,
467 filter_ref: git_ref,
468 paths: None,
469 compare: None,
470 payload,
471 title: format!("Release {name} {}", action.unwrap_or("changed")),
472 trusted: true,
473 approval: None,
474 })
475 }
476 // A deployment, or a new status of one: at the commit deployed,
477 // on the branch or tag it names (none for a bare commit).
478 "deployment" | "deployment_status" => {
479 let deployment = &data["deployment"];
480 let Some(sha) = deployment["sha"].as_str().filter(|sha| !sha.is_empty()).map(str::to_owned) else { return Ok(None) };
481 let named = deployment["ref"].as_str().unwrap_or_default();
482 let git_ref = if named.is_empty() || named == sha || is_commit(named) {
483 String::new()
484 } else if named.starts_with("refs/") {
485 named.to_owned()
486 } else if self.is_branch(repo, ws, named).await? {
487 format!("refs/heads/{named}")
488 } else {
489 format!("refs/tags/{named}")
490 };
491 let environment = deployment["environment"].as_str().unwrap_or_default();
492 let mut payload = json!({
493 "action": "created",
494 "deployment": payload::deployment(repo, deployment),
495 "repository": payload::repository(repo),
496 "sender": payload::user(sender),
497 });
498 let title = if event_name == "deployment_status" {
499 let status = &data["deploymentStatus"];
500 payload["deployment_status"] = payload::deployment_status(repo, status, deployment);
501 format!("Deployment to {environment}: {}", status["state"].as_str().unwrap_or("changed"))
502 } else {
503 format!("Deployment to {environment}")
504 };
505 Some(Subject {
506 source: path.clone(),
507 source_ref: Some(sha.clone()),
508 filter_ref: git_ref.clone(),
509 git_ref,
510 sha,
511 head_ref: None,
512 base_ref: None,
513 pull: None,
514 paths: None,
515 compare: None,
516 payload,
517 title,
518 trusted: true,
519 approval: None,
520 })
521 }
A flat project menu with Code first, views as tabs, and Stripe for every owner522 _ => None,
523 })
524 }
525
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails526 /// A push keeps the repository's schedules going (`run_schedules`).
527 /// Written at most once a day, and only on scheduled workflows.
528 async fn note_push(&self, repo_id: &str) -> Result<()> {
529 let at = now_ms();
530 self.db
531 .prepare("UPDATE workflows SET pushed_at = ? WHERE repo_id = ? AND crons != '[]' AND (pushed_at IS NULL OR pushed_at < ?)")
532 .bind(&[rfc3339(at).into(), repo_id.into(), rfc3339(at.saturating_sub(24 * 60 * 60 * 1000)).into()])?
533 .run()
534 .await?;
535 Ok(())
536 }
537
A flat project menu with Code first, views as tabs, and Stripe for every owner538 pub async fn on_event(&self, event: &Event) -> Result<()> {
539 let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'540 let mut mapped = github_events(&event.kind);
541 // A new branch or tag is also `create`.
542 if event.kind == "git.push" && event.data["before"].is_null() {
543 mapped.push(("create", None));
544 }
A flat project menu with Code first, views as tabs, and Stripe for every owner545 let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true);
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails546 if event.kind == "git.push" {
547 self.note_push(repo_id).await?;
548 }
A flat project menu with Code first, views as tabs, and Stripe for every owner549 if mapped.is_empty() && !pushed_default {
550 return Ok(());
551 }
552 let Some((repo, ws)) = self.repo_by_id(repo_id).await? else { return Ok(()) };
553 if pushed_default {
554 self.sync(&repo, &ws).await?;
555 }
Merge branch 'mirroring' into artifacts-mode556 // A mirror runs only what its state says (mirrored.rs).
557 let copied_in = event.kind == "git.push" && event.data["mirrored"].as_bool() == Some(true);
558 let policy = crate::mirrored::policy(repo.mirror.as_ref(), copied_in);
559 if !policy.runs {
560 return Ok(());
561 }
Merge branch 'worktree-agent-a3abfcce648e87dca'562 // What a workflow job's own token did starts no workflows, as on
563 // GitHub, so a workflow cannot set itself off; only
564 // `workflow_dispatch` and `repository_dispatch` do.
565 if let Some(run) = caused_by_job(&event.data) {
566 worker::console_log!("actions: {} {} came from run {run}'s token; no workflows start for it", event.kind, event.id);
567 return Ok(());
568 }
A flat project menu with Code first, views as tabs, and Stripe for every owner569 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
570 for (event_name, action) in mapped {
Fewer Artifacts reads: the store is asked for a handle only when needed, objects are kept in the isolate, and issue events read no workflows nobody listens for571 // Issues and comments start the default branch's workflows,
572 // which the synced table lists: when none listens, nothing is
573 // read from git. Agents make many of these events.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts574 // Deployments' statuses are as frequent (every g1t.page build
575 // reports several), so they look there first too.
576 if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status")
577 && self.listens(repo_id, event_name).await? == Some(false)
578 {
Fewer Artifacts reads: the store is asked for a handle only when needed, objects are kept in the isolate, and issue events read no workflows nobody listens for579 continue;
580 }
A flat project menu with Code first, views as tabs, and Stripe for every owner581 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
582 continue;
583 };
What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so.584 // `[skip ci]` in the head commit's message, as on GitHub.
585 if matches!(event_name, "push" | "pull_request") {
586 let message = match subject.payload["head_commit"]["message"].as_str() {
587 Some(message) => Some(message.to_owned()),
588 None => self.head_message(&subject.source, &ws, &subject.sha).await?,
589 };
590 if message.as_deref().is_some_and(g1t_actions::events::skips_ci) {
591 worker::console_log!("actions: {} {} at {} asks to skip its runs", event.kind, event.id, subject.sha);
592 continue;
593 }
594 }
Merge branch 'mirroring' into artifacts-mode595 // A change to workflows made on the remote, by someone g1t
596 // knows nothing of, waits before it may use this repository's
597 // secrets.
598 if copied_in && matches!(event_name, "push" | "create") {
599 if subject.paths.is_none() {
600 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
601 subject.paths = Some(self.changed_paths(&repo, &ws, base, head).await?);
602 }
603 if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default())
604 && let Some(mirror) = &repo.mirror
605 {
606 subject.approval = subject.approval.take().or_else(|| Some(crate::mirrored::copied_workflows(&mirror.remote)));
607 }
608 }
609 // A pull request from a fork reads the fork's files.
610 let read = if subject.source == Self::repo_path(&repo) {
611 self.read_for(&repo, &ws, subject.source_ref.as_deref(), policy.github).await?
612 } else {
613 self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?
614 };
A flat project menu with Code first, views as tabs, and Stripe for every owner615 // A pull request's head runs each workflow once, however many
616 // events say it is there (marked ready, and pushed).
617 let key = match subject.pull {
618 Some(number) if event_name.starts_with("pull_request") && event_name != "pull_request_review" => {
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts619 // Reopened or made a draft again runs anew, at a head
620 // that may have run before.
621 let phase = match action {
622 Some("closed") => "closed".to_owned(),
623 Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id),
624 _ => "open".to_owned(),
625 };
A flat project menu with Code first, views as tabs, and Stripe for every owner626 format!("{event_name}:{number}:{}:{phase}", subject.sha)
627 }
Merge branch 'worktree-agent-a3abfcce648e87dca'628 _ if event_name == "create" => format!("{}:create", event.id),
A flat project menu with Code first, views as tabs, and Stripe for every owner629 _ => event.id.clone(),
630 };
631 self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender)
632 .await?;
633 }
634 Ok(())
635 }
636
637 #[allow(clippy::too_many_arguments)]
638 async fn start_matching(
639 &self,
640 repo: &Repo,
641 ws: &User,
642 read: Read,
643 subject: &mut Subject,
644 event_name: &str,
645 action: Option<&str>,
646 event_key: &str,
647 actor_id: Option<&str>,
648 sender: &str,
649 ) -> Result<()> {
650 for file in read.files {
651 let parsed = workflow::parse(&file.source);
652 let workflow = match parsed {
653 Ok(workflow) => workflow,
654 Err(problem) => {
655 // A push shows a broken workflow as a failed run, as GitHub does.
656 if event_name == "push" && file.source.contains("on") {
657 self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
658 .await?;
659 }
660 continue;
661 }
662 };
663 let Some(trigger) = workflow.trigger(event_name) else { continue };
664 // workflow_run follows the workflows it names.
665 if event_name == "workflow_run" {
666 let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default();
667 if !trigger.workflows.iter().any(|name| name == followed) {
668 continue;
669 }
670 }
671 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
672 continue;
673 }
674 if self.disabled(&repo.id, &file.path).await? {
675 continue;
676 }
Merge branch 'mirroring' into artifacts-mode677 // On a mirror the remote may deploy too: a workflow that deploys
678 // waits for approval (mirrored.rs).
679 let held = crate::mirrored::policy(repo.mirror.as_ref(), false)
680 .hold
681 .zip(crate::mirrored::deploys_to(&workflow))
682 .map(|(remote, environment)| crate::mirrored::held(&remote, &environment));
A flat project menu with Code first, views as tabs, and Stripe for every owner683 self.create_run(NewRun {
684 repo: repo.clone(),
685 path: file.path,
686 source: file.source,
687 info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id),
688 workflow,
689 action: action.map(str::to_owned),
690 pull: subject.pull,
691 title: subject.title.clone(),
692 inputs: Map::new(),
693 event_key: event_key.to_owned(),
694 actor_id: actor_id.map(str::to_owned),
695 actor: Some(sender.to_owned()),
696 trusted: subject.trusted,
Merge branch 'mirroring' into artifacts-mode697 approval: subject.approval.clone().or(held),
A flat project menu with Code first, views as tabs, and Stripe for every owner698 })
699 .await?;
700 }
701 Ok(())
702 }
703
704 /// Whether the branch, tag and path filters let the event through.
705 async fn passes(&self, repo: &Repo, ws: &User, trigger: &Trigger, subject: &mut Subject, event_name: &str) -> Result<bool> {
706 let git_ref = subject.filter_ref.as_str();
707 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
708 // A tag push runs a workflow that filters tags, or filters nothing.
709 if trigger.tags.is_set() {
710 if !trigger.tags.allows(tag) {
711 return Ok(false);
712 }
713 } else if trigger.branches.is_set() {
714 return Ok(false);
715 }
716 // Paths are not checked for tags, as on GitHub.
717 return Ok(true);
718 }
719 let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref);
720 if trigger.branches.is_set() {
721 if !trigger.branches.allows(branch) {
722 return Ok(false);
723 }
724 } else if event_name == "push" && trigger.tags.is_set() {
725 return Ok(false);
726 }
727 if trigger.paths.is_set() {
728 if subject.paths.is_none() {
729 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
730 subject.paths = Some(self.changed_paths(repo, ws, base, head).await?);
731 }
732 if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
733 return Ok(false);
734 }
735 }
736 Ok(true)
737 }
738
739 async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> {
740 let row = self
741 .db
742 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
743 .bind(&[repo_id.into(), path.into()])?
744 .first::<WorkflowRow>(None)
745 .await?;
746 Ok(row.is_some_and(|row| row.state == "disabled"))
747 }
748
749 fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo {
750 RunInfo {
751 repository: format!("{}/{}", repo.namespace, repo.name),
752 repository_id: repo.id.clone(),
753 default_branch: repo.default_branch.clone(),
754 event_name: event_name.to_owned(),
755 event: subject.payload.clone(),
756 git_ref: subject.git_ref.clone(),
757 sha: subject.sha.clone(),
758 head_ref: subject.head_ref.clone(),
759 base_ref: subject.base_ref.clone(),
760 actor: sender.to_owned(),
761 actor_id: actor_id.unwrap_or_default().to_owned(),
762 triggering_actor: sender.to_owned(),
763 run_id: String::new(),
764 run_number: 0,
765 run_attempt: 1,
766 workflow: workflow.name.clone().unwrap_or_default(),
767 workflow_path: String::new(),
768 server_url: SITE.to_owned(),
769 api_url: API.to_owned(),
770 }
771 }
772
773 #[allow(clippy::too_many_arguments)]
774 async fn record_invalid(
775 &self,
776 repo: &Repo,
777 path: &str,
778 source: &str,
779 subject: &Subject,
780 event_key: &str,
781 actor_id: Option<&str>,
782 sender: &str,
783 problem: &str,
784 ) -> Result<()> {
785 let row = self.workflow_row(repo, path, path, source).await?;
786 self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
787 }
788
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails789 /// Scheduled workflows that run this minute, on the default branch: at
790 /// most every five minutes (`cron::Schedule::runs_at`). Not in a
791 /// repository with no push for [`crate::SCHEDULE_IDLE_MS`], nor for an
792 /// hour after billing refused one of the workflow's scheduled jobs
793 /// ([`crate::SCHEDULE_REFUSED_MS`]): no run is made only to be refused.
A flat project menu with Code first, views as tabs, and Stripe for every owner794 pub async fn run_schedules(&self, minute: u64) -> Result<()> {
795 let rows = self
796 .db
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails797 .prepare(
798 "SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL
799 AND COALESCE(pushed_at, updated_at) >= ? AND (schedule_refused_until IS NULL OR schedule_refused_until <= ?)",
800 )
801 .bind(&[rfc3339(minute.saturating_sub(crate::SCHEDULE_IDLE_MS)).into(), rfc3339(minute).into()])?
A flat project menu with Code first, views as tabs, and Stripe for every owner802 .all()
803 .await?
804 .results::<WorkflowRow>()?;
805 for row in rows {
806 let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails807 let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.runs_at(minute))) else {
A flat project menu with Code first, views as tabs, and Stripe for every owner808 continue;
809 };
810 let Ok(workflow) = workflow::parse(&row.source) else { continue };
Merge branch 'mirroring' into artifacts-mode811 // Schedules wait while a repository is archived, or a mirror runs
812 // nothing; a deleted one is not found.
813 let Some((repo, _ws)) = self
814 .repo_by_id(&row.repo_id)
815 .await?
816 .filter(|(repo, _)| !repo.archived() && crate::mirrored::policy(repo.mirror.as_ref(), false).runs)
817 else {
818 continue;
819 };
A flat project menu with Code first, views as tabs, and Stripe for every owner820 let Some(sha) = self.default_head(&repo).await? else { continue };
821 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
822 let mut subject = Subject {
823 source: Self::repo_path(&repo),
824 source_ref: None,
825 git_ref: format!("refs/heads/{}", repo.default_branch),
826 sha,
827 head_ref: None,
828 base_ref: None,
829 pull: None,
830 filter_ref: String::new(),
831 paths: None,
832 compare: None,
833 payload,
834 title: format!("Scheduled: {cron}"),
835 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'836 approval: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner837 };
838 subject.filter_ref = subject.git_ref.clone();
839 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
840 self.create_run(NewRun {
841 repo: repo.clone(),
842 path: row.path.clone(),
843 source: row.source.clone(),
844 workflow,
845 info,
846 action: None,
847 pull: None,
848 title: subject.title.clone(),
849 inputs: Map::new(),
850 event_key: format!("schedule:{minute}"),
851 actor_id: None,
852 actor: None,
853 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'854 approval: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner855 })
856 .await?;
857 }
858 Ok(())
859 }
860
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look861 /// `dispatch`: someone with the Write role runs a workflow that has
862 /// `workflow_dispatch`.
A flat project menu with Code first, views as tabs, and Stripe for every owner863 pub async fn dispatch(&self, a: DispatchArgs) -> Result<Outcome<WorkflowRun>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look864 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
865 if repo.archived() {
866 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
A flat project menu with Code first, views as tabs, and Stripe for every owner867 }
868 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
869 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
870 };
871 let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
872 let full_ref = if git_ref.starts_with("refs/") {
873 git_ref.clone()
874 } else {
875 // A branch if there is one by that name, otherwise a tag.
876 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
877 &self.repos,
878 "branches",
879 &g1t_contracts::repos::BranchesArgs {
880 path: Self::repo_path(&repo),
881 viewer: Some(ws.clone()),
882 },
883 )
884 .await?;
885 let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
886 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
887 };
888 let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
Merge branch 'mirroring' into artifacts-mode889 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
890 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
891 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
892 }
893 let read = self.read_for(&repo, &ws, Some(&short), policy.github).await?;
A flat project menu with Code first, views as tabs, and Stripe for every owner894 let Some(sha) = read.head.clone() else {
895 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
896 };
Search across all of g1t, Explore, and a command palette897 // A workflow is named by its file (`build.yml`), its path, or its id
898 // (`wfl_…`), which stands for the path it was read from.
899 let by_id = if a.workflow.starts_with("wfl_") {
900 self.db
901 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?")
902 .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])?
903 .first::<WorkflowRow>(None)
904 .await?
905 .map(|row| row.path)
906 } else {
907 None
908 };
909 let named = by_id.as_deref().unwrap_or(&a.workflow);
910 let wanted = named.trim_start_matches(".g1t/workflows/");
A flat project menu with Code first, views as tabs, and Stripe for every owner911 let Some(file) = read.files.iter().find(|file| {
Search across all of g1t, Explore, and a command palette912 file.path.rsplit('/').next() == Some(wanted) || file.path == named
A flat project menu with Code first, views as tabs, and Stripe for every owner913 }) else {
914 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
915 };
916 let workflow = match workflow::parse(&file.source) {
917 Ok(workflow) => workflow,
918 Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
919 };
920 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
921 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
922 };
923 let inputs = check!(dispatch_inputs(trigger, &a.inputs));
924 let payload = json!({
925 "inputs": inputs,
926 "ref": full_ref,
927 "repository": payload::repository(&repo),
928 "sender": payload::user(&a.actor.username),
929 "workflow": file.path,
930 });
931 let subject = Subject {
932 source: Self::repo_path(&repo),
933 source_ref: Some(sha.clone()),
934 git_ref: full_ref.clone(),
935 sha,
936 head_ref: None,
937 base_ref: None,
938 pull: None,
939 filter_ref: full_ref,
940 paths: None,
941 compare: None,
942 payload,
943 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
944 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'945 approval: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner946 };
947 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
948 let created = self
949 .create_run(NewRun {
950 repo: repo.clone(),
951 path: file.path.clone(),
952 source: file.source.clone(),
953 workflow,
954 info,
955 action: None,
956 pull: None,
957 title: subject.title.clone(),
958 inputs,
959 event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
960 actor_id: Some(a.actor.id.clone()),
961 actor: Some(a.actor.username.clone()),
962 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'963 approval: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner964 })
965 .await?;
966 match created {
967 Some(id) => self.run_summary(&id).await,
968 None => Ok(fail(FailureCode::Conflict, "It did not start.")),
969 }
970 }
Merge branch 'worktree-agent-a3abfcce648e87dca'971
972 /// `repository_dispatch`: an outside event, by name, starts the default
973 /// branch's workflows that run `on: repository_dispatch` with that type
974 /// (or with no `types`). A workflow job's token may send one: this,
975 /// with `workflow_dispatch`, is how a workflow starts another.
976 pub async fn repository_dispatch(&self, a: RepositoryDispatchArgs) -> Result<Outcome<u32>> {
977 let repo = check!(self.may(&a.actor, &a.repo, Capability::Push).await?);
978 if repo.archived() {
979 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
980 }
981 let event_type = a.event_type.trim().to_owned();
982 if event_type.is_empty() || event_type.chars().count() > 100 {
983 return Ok(fail(FailureCode::Invalid, "event_type is 1 to 100 characters."));
984 }
985 let client_payload = match a.client_payload {
986 Value::Null => json!({}),
987 Value::Object(map) if map.len() <= 10 => Value::Object(map),
988 Value::Object(_) => return Ok(fail(FailureCode::Invalid, "client_payload has at most 10 top-level properties.")),
989 _ => return Ok(fail(FailureCode::Invalid, "client_payload is a JSON object.")),
990 };
991 if serde_json::to_string(&client_payload).map_or(0, |text| text.len()) > 64 * 1024 {
992 return Ok(fail(FailureCode::Invalid, "client_payload is at most 64 KB."));
993 }
994 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
995 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
996 };
Merge branch 'mirroring' into artifacts-mode997 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
998 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
999 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1000 }
1001 let read = self.read_for(&repo, &ws, Some(&repo.default_branch), policy.github).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1002 let Some(sha) = read.head.clone() else {
1003 return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet."));
1004 };
1005 let git_ref = format!("refs/heads/{}", repo.default_branch);
1006 let payload = json!({
1007 "action": event_type,
1008 "branch": repo.default_branch,
1009 "client_payload": client_payload,
1010 "repository": payload::repository(&repo),
1011 "sender": payload::user(&a.actor.username),
1012 });
1013 let key = format!("repository_dispatch:{}", new_id("dsp", now_ms()));
1014 let mut started = 0u32;
1015 for file in read.files {
1016 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1017 let Some(trigger) = workflow.trigger("repository_dispatch") else { continue };
1018 if !trigger.wants_type(Some(&event_type)) || self.disabled(&repo.id, &file.path).await? {
1019 continue;
1020 }
1021 let subject = Subject {
1022 source: Self::repo_path(&repo),
1023 source_ref: Some(sha.clone()),
1024 git_ref: git_ref.clone(),
1025 sha: sha.clone(),
1026 head_ref: None,
1027 base_ref: None,
1028 pull: None,
1029 filter_ref: git_ref.clone(),
1030 paths: None,
1031 compare: None,
1032 payload: payload.clone(),
1033 title: event_type.clone(),
1034 trusted: true,
1035 approval: None,
1036 };
1037 let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1038 let created = self
1039 .create_run(NewRun {
1040 repo: repo.clone(),
1041 path: file.path.clone(),
1042 source: file.source.clone(),
1043 workflow,
1044 info,
1045 action: Some(event_type.clone()),
1046 pull: None,
1047 title: subject.title.clone(),
1048 inputs: Map::new(),
1049 event_key: key.clone(),
1050 actor_id: Some(a.actor.id.clone()),
1051 actor: Some(a.actor.username.clone()),
1052 trusted: true,
1053 approval: None,
1054 })
1055 .await?;
1056 if created.is_some() {
1057 started += 1;
1058 }
1059 }
1060 Ok(Outcome::Ok(started))
1061 }
A flat project menu with Code first, views as tabs, and Stripe for every owner1062}
1063
1064#[derive(serde::Deserialize)]
1065#[serde(rename_all = "camelCase")]
1066pub struct MergeGroupArgs {
1067 pub repo_id: String,
1068 pub entry: String,
1069 pub sha: String,
1070 pub head_ref: String,
1071 #[serde(default)]
1072 pub base_sha: Option<String>,
1073 pub number: u32,
1074 #[serde(default)]
1075 pub ahead: Vec<u32>,
1076}
1077
1078impl Actions {
1079 /// `merge_group`: the merge queue built a state and its checks passed.
1080 /// Starts the workflows that run `on: merge_group` on it, as GitHub's
1081 /// queue does, and says how many started; the queue waits for their
1082 /// statuses on that commit.
1083 pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
1084 let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
1085 return Ok(Outcome::Ok(json!({ "runs": 0 })));
1086 };
1087 let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
1088 let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
1089 let payload = json!({
1090 "action": "checks_requested",
1091 "merge_group": {
1092 "head_sha": a.sha,
1093 "head_ref": a.head_ref,
1094 "base_sha": a.base_sha,
1095 "base_ref": format!("refs/heads/{}", repo.default_branch),
1096 "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
1097 },
1098 "repository": payload::repository(&repo),
1099 "sender": payload::user(&repo.namespace),
1100 });
1101 let mut started = 0u32;
1102 for file in read.files {
1103 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1104 let Some(trigger) = workflow.trigger("merge_group") else { continue };
1105 if !trigger.wants_type(Some("checks_requested")) || self.disabled(&repo.id, &file.path).await? {
1106 continue;
1107 }
1108 // Branch filters on merge_group name the branch it merges into.
1109 if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
1110 continue;
1111 }
1112 let ahead = if a.ahead.is_empty() {
1113 String::new()
1114 } else {
1115 format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
1116 };
1117 let subject = Subject {
1118 source: Self::repo_path(&repo),
1119 source_ref: Some(a.sha.clone()),
1120 git_ref: a.head_ref.clone(),
1121 sha: a.sha.clone(),
1122 head_ref: None,
1123 base_ref: Some(repo.default_branch.clone()),
1124 pull: Some(a.number),
1125 filter_ref: format!("refs/heads/{}", repo.default_branch),
1126 paths: None,
1127 compare: None,
1128 payload: payload.clone(),
1129 title: format!("Merge queue: #{}{ahead}", a.number),
1130 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1131 approval: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner1132 };
1133 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
1134 let created = self
1135 .create_run(NewRun {
1136 repo: repo.clone(),
1137 path: file.path.clone(),
1138 source: file.source.clone(),
1139 workflow,
1140 info,
1141 action: Some("checks_requested".to_owned()),
1142 pull: Some(a.number),
1143 title: subject.title.clone(),
1144 inputs: Map::new(),
1145 event_key: format!("merge_group:{}:{}", a.entry, a.sha),
1146 actor_id: None,
1147 actor: None,
1148 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1149 approval: None,
A flat project menu with Code first, views as tabs, and Stripe for every owner1150 })
1151 .await?;
1152 if created.is_some() {
1153 started += 1;
1154 }
1155 }
1156 Ok(Outcome::Ok(json!({ "runs": started })))
1157 }
1158}
1159
1160/// The inputs of a manual run: what was given, checked against the
1161/// workflow's declared inputs, with their defaults filled in.
1162fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>) -> Outcome<Map<String, Value>> {
1163 let mut inputs = Map::new();
1164 for (name, spec) in &trigger.inputs {
1165 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
1166 let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
1167 let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
1168 let value = match value {
1169 Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
1170 Some(Value::Null) | None => match kind {
1171 "boolean" => Value::Bool(false),
1172 _ => Value::String(String::new()),
1173 },
1174 Some(value) => match kind {
1175 "boolean" => Value::Bool(match &value {
1176 Value::Bool(flag) => *flag,
1177 Value::String(text) => text == "true",
1178 _ => false,
1179 }),
1180 "number" => match &value {
1181 Value::Number(_) => value,
1182 Value::String(text) => match text.parse::<f64>().ok().and_then(serde_json::Number::from_f64) {
1183 Some(number) => Value::Number(number),
1184 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1185 },
1186 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1187 },
1188 "choice" => {
1189 let text = g1t_actions::expr::to_text(&value);
1190 let options: Vec<String> =
1191 spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
1192 if !options.is_empty() && !options.contains(&text) {
1193 return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
1194 }
1195 Value::String(text)
1196 }
1197 _ => Value::String(g1t_actions::expr::to_text(&value)),
1198 },
1199 };
1200 inputs.insert(name.clone(), value);
1201 }
1202 Outcome::Ok(inputs)
1203}
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208 use g1t_contracts::work::{Pull, g1t_author};
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1209
1210 #[test]
1211 fn every_event_a_workflow_runs_on_is_sent_to_this_queue() {
1212 for kind in g1t_contracts::webhooks::EVENT_TYPES {
1213 if !github_events(kind).is_empty() {
1214 assert!(
1215 g1t_contracts::subscribers::routed("SUBSCRIBER_ACTIONS", kind),
1216 "{kind} starts workflows but is not routed to actions (g1t_contracts::subscribers)"
1217 );
1218 }
1219 }
1220 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1221 use g1t_contracts::{Membership, PrincipalKind};
1222
1223 fn repo() -> Repo {
1224 serde_json::from_value(json!({
1225 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
1226 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
1227 }))
1228 .unwrap()
1229 }
1230
1231 fn person(id: &str, username: &str) -> User {
1232 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
1233 }
1234
1235 fn made_for(asker: User) -> Pull {
1236 serde_json::from_value(json!({
1237 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
1238 "agent": "g1t", "runtime": "hosted", "status": "open",
1239 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
1240 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
1241 "supersededBy": null, "checkStatus": null,
1242 "author": g1t_author(), "requestedBy": asker,
1243 "createdAt": "", "updatedAt": ""
1244 }))
1245 .unwrap()
1246 }
1247
1248 #[test]
1249 fn g1t_s_change_for_someone_is_trusted_as_they_are() {
1250 // Stored people carry no memberships, so identity is asked about
1251 // them; being g1t's change gives it nothing more.
1252 let pull = made_for(person("usr_2", "ana"));
1253 assert!(!trusted_outright(pull.owner(), &repo()));
1254 // Someone known to be able to push is trusted at once.
1255 let mut member = person("usr_1", "syntaqx");
1256 member.workspaces.push(Membership::member("acme"));
1257 let pull = made_for(member);
1258 assert!(trusted_outright(pull.owner(), &repo()));
1259 }
1260
1261 #[test]
1262 fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() {
1263 let pull = made_for(person("usr_1", "syntaqx"));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1264 let event = payload::pull(&repo(), &pull);
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1265 assert_eq!(event["user"]["login"], "g1t");
1266 assert_eq!(event["user"]["type"], "Bot");
1267 assert_eq!(event["requested_by"]["login"], "syntaqx");
1268 assert_eq!(event["requested_by"]["type"], "User");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1269 let as_issue = payload::pull_as_issue(&repo(), &pull);
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1270 assert_eq!(as_issue["user"]["login"], "g1t");
1271 assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
1272 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1273
1274 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1275 fn releases_deployments_and_deleted_comments_read_as_githubs() {
1276 let release = payload::release(
1277 &repo(),
1278 &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false,
1279 "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }),
1280 );
1281 assert_eq!(release["tag_name"], "v1.2.0");
1282 assert_eq!(release["name"], "v1.2.0");
1283 assert_eq!(release["prerelease"], true);
1284 assert_eq!(release["author"]["login"], "ana");
1285 assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0");
1286 let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana",
1287 "production_environment": false, "created_at": "t", "updated_at": "t" });
1288 let status = payload::deployment_status(&repo(), &json!({ "id": "dst_1", "state": "success", "environment_url": "https://s.example", "log_url": null, "creator": "g1t", "created_at": "t" }), &deployment);
1289 assert_eq!(status["state"], "success");
1290 assert_eq!(status["environment"], "staging");
1291 assert_eq!(status["environment_url"], "https://s.example");
1292 assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1293 let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1294 assert_eq!(gone["user"]["login"], "bo");
1295 assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1296 assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
1297 assert!(!is_commit("main"));
1298 }
1299
1300 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1301 fn a_pull_request_names_its_own_base_labels_and_milestone() {
1302 let mut pull = made_for(person("usr_1", "syntaqx"));
1303 let event = payload::pull(&repo(), &pull);
1304 assert_eq!(event["base"]["ref"], repo().default_branch);
1305 pull.base = Some("release/1.x".into());
1306 pull.labels = vec!["bug".into()];
1307 pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() });
1308 let event = payload::pull(&repo(), &pull);
1309 assert_eq!(event["base"]["ref"], "release/1.x");
1310 assert_eq!(event["labels"], serde_json::json!([{ "name": "bug" }]));
1311 assert_eq!(event["milestone"]["title"], "1.1");
1312 let mut labeled = serde_json::json!({ "action": "labeled" });
1313 payload::changed(&mut labeled, &serde_json::json!({ "label": { "name": "bug", "color": "d73a4a" } }));
1314 assert_eq!(labeled["label"]["color"], "d73a4a");
1315 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1316}

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