Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,694 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.

Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1//! 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};
7use 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};
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths10use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.11use g1t_contracts::repos::{Commit, CompareArgs, Comparison, FileStatus, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath};
12use g1t_contracts::work::{IssueDetail, Label, PullDetail, ViewArgs};
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths13use g1t_contracts::{FailureCode, Outcome, User, new_id};
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails14use g1t_contracts::time::rfc3339;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths15use 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
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'23/// How many commits a push's log reads: its `commits`, and how far back
24/// `before` is looked for to tell a forced push.
25const PUSH_LOG: u32 = 20;
26
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths27/// What an event is about, worked out once for every workflow it starts.
28struct Subject {
29 /// Where the workflow files are read, and at which commit.
30 source: RepoPath,
31 source_ref: Option<String>,
32 git_ref: String,
33 sha: String,
34 head_ref: Option<String>,
35 base_ref: Option<String>,
36 pull: Option<u32>,
37 /// The branch or tag for `branches`/`tags` filters; for pull requests,
38 /// the branch they merge into.
39 filter_ref: String,
40 /// The files it changes, for `paths` filters; `None` until needed.
41 paths: Option<Vec<String>>,
42 /// For a push, what to compare to find the files.
43 compare: Option<(Option<String>, String)>,
44 payload: Value,
45 title: String,
46 trusted: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'47 /// Why its runs wait for approval first: a pull request from outside,
48 /// by the repository's approval policy (protection.rs).
49 approval: Option<String>,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'50 /// What the run builds when it is not `sha`: a pull request's test
51 /// merge commit (`refs/pull/<n>/merge`), as on GitHub, which becomes
52 /// `github.sha` while statuses and checks stay on `sha`, its head.
53 merge: Option<String>,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths54}
55
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.56impl Subject {
57 /// What the event changes, once compared: the paths for `paths`
58 /// filters, and a push's commit's `added`, `removed` and `modified`.
59 fn changed(&mut self, changes: Vec<(String, FileStatus)>) {
60 payload::push_changes(&mut self.payload, &changes);
61 self.paths = Some(changes.into_iter().map(|(path, _)| path).collect());
62 }
63}
64
65/// The address a push's `pusher.email` gives: g1t's for its agent and
66/// itself, a person's noreply address (which needs no lookup) for them,
67/// and none for anyone else.
68fn pusher_email(actor: Option<&str>, username: &str) -> Option<String> {
69 match actor? {
70 id if id == AGENT_ID || id == g1t_contracts::system::ID => Some(g1t_contracts::system::EMAIL.to_owned()),
71 id if id.starts_with("usr_") => Some(g1t_contracts::accounts::noreply_address(id, username)),
72 _ => None,
73 }
74}
75
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts76/// Whether a deployment's `ref` is a commit's full hash rather than a
77/// branch or tag.
78fn is_commit(name: &str) -> bool {
79 name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit())
80}
81
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r982/// What a ref made or deleted is, as `create` and `delete` name it:
83/// `branch` or `tag`, and its short name. `None` for any other ref, and
84/// for the merge queue's own branches, which come and go with every
85/// queued change.
86fn branch_or_tag(git_ref: &str) -> Option<(&'static str, &str)> {
87 if git_ref.starts_with("refs/heads/g1t-queue/") {
88 return None;
89 }
90 match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) {
91 (Some(branch), _) => Some(("branch", branch)),
92 (_, Some(tag)) => Some(("tag", tag)),
93 _ => None,
94 }
95}
96
97/// Events GitHub takes no `branches`, `tags` or `paths` filters on.
98fn unfiltered(event_name: &str) -> bool {
99 matches!(event_name, "create" | "delete")
100}
101
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'102/// What a pull request's run builds, given its test merge commit.
103#[derive(Debug, PartialEq, Eq)]
104enum PullBuild {
105 /// The merge commit, as on GitHub.
106 Merge(String),
107 /// The head: g1t could not make the merge commit itself, or a
108 /// conflicting pull request's event that still runs without one.
109 Head,
110 /// No run: GitHub starts no `pull_request` workflow while the pull
111 /// request has conflicts, since there is no merge commit to build. The
112 /// pull request's checks say so (apps/web `pull.tsx`).
113 Nothing,
114}
115
116fn pull_build(event_name: &str, action: Option<&str>, made: &PullMergeRef) -> PullBuild {
117 match made {
118 PullMergeRef::Merged { merge, .. } => PullBuild::Merge(merge.clone()),
119 PullMergeRef::Conflict { .. } if event_name == "pull_request" && action != Some("closed") => PullBuild::Nothing,
120 PullMergeRef::Conflict { .. } | PullMergeRef::Unavailable { .. } => PullBuild::Head,
121 }
122}
123
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9124/// Whether an `*.assigned` event added nobody: g1t says so for a change
125/// that only took people off.
126fn nobody_added(data: &Value) -> bool {
127 data["added"].as_array().is_some_and(Vec::is_empty)
128}
129
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths130/// Whether whoever a pull request is for is trusted without asking
The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were.131/// identity: someone whose role here is known to allow pushing.
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths132fn trusted_outright(owner: &User, repo: &Repo) -> bool {
The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were.133 access::can(Some(owner), repo, Capability::Push)
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths134}
135
136impl Actions {
137 async fn username(&self, id: Option<&str>) -> Result<Option<String>> {
138 let Some(id) = id else { return Ok(None) };
139 if id == AGENT_ID {
140 return Ok(Some(AGENT_NAME.to_owned()));
141 }
142 let names: std::collections::HashMap<String, String> =
143 g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.to_owned()] }).await?;
144 Ok(names.get(id).cloned())
145 }
146
147 /// Whether whoever a pull request is for (Pull::owner: whoever asked
148 /// g1t for it, or its author) could push to the repository, so its
149 /// runs get the secrets and a token. Anyone else's, a reader's included
150 /// (who may open one on a private repository too), runs without them.
151 /// A change g1t made for someone is trusted as they are.
152 async fn insider(&self, owner: &User, repo: &Repo, ws: &User) -> Result<bool> {
153 if trusted_outright(owner, repo) {
154 return Ok(true);
155 }
156 // Stored authors carry no memberships or grants: ask identity, as
157 // the workspace (which may see anyone's permission).
158 let permission: Outcome<access::PermissionInfo> = g1t_kit::call(
159 &self.identity,
160 "collaborator_permission",
161 &access::CollaboratorPermissionArgs {
162 viewer: Some(ws.clone()),
163 path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
164 username: owner.username.clone(),
165 },
166 )
167 .await?;
168 Ok(permission
169 .into_result()
170 .ok()
171 .and_then(|info| info.role)
172 .is_some_and(|role| access::allows(role, Capability::Push)))
173 }
174
175 async fn commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<Vec<Commit>> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'176 Ok(self.pushed_commits(repo, actor, after, before).await?.0)
177 }
178
179 /// The commits a push added, oldest first, and whether it was forced:
180 /// `before` is not in `after`'s history. Known only when that history
181 /// is read whole (it is shorter than the [`PUSH_LOG`] commits read);
182 /// a longer one says not forced.
183 async fn pushed_commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<(Vec<Commit>, bool)> {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths184 let log: Outcome<Vec<Commit>> = g1t_kit::call(
185 &self.repos,
186 "log",
187 &LogArgs {
188 path: RepoPath {
189 namespace: repo.namespace.clone(),
190 name: repo.name.clone(),
191 },
192 viewer: Some(actor.clone()),
193 git_ref: Some(after.to_owned()),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'194 limit: PUSH_LOG,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths195 },
196 )
197 .await?;
198 let mut commits: Vec<Commit> = log.into_result().unwrap_or_default();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'199 let whole = commits.len() < PUSH_LOG as usize;
200 let mut forced = false;
201 if let Some(before) = before.filter(|before| *before != payload::ZERO) {
202 match commits.iter().position(|commit| commit.hash == before) {
203 Some(at) => commits.truncate(at),
204 None => forced = whole && !commits.is_empty(),
205 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths206 }
207 // GitHub lists them oldest first, with the head commit last.
208 commits.reverse();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'209 Ok((commits, forced))
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths210 }
211
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.212 /// The message of the commit `sha` in `path`, when it can be read.
213 async fn head_message(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<String>> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'214 Ok(self.commit_at(path, actor, sha).await?.map(|commit| commit.message))
215 }
216
217 /// The commit `sha` in `path`, when it can be read.
218 async fn commit_at(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<Commit>> {
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.219 let log: Outcome<Vec<Commit>> = g1t_kit::call(
220 &self.repos,
221 "log",
222 &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 },
223 )
224 .await?;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'225 Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next()))
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.226 }
227
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.228 /// What `head` changes from `base`: each path and how.
229 async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<(String, FileStatus)>> {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths230 let compared: Outcome<Comparison> = g1t_kit::call(
231 &self.repos,
232 "compare",
233 &CompareArgs {
234 repo_id: repo.id.clone(),
235 viewer: Some(actor.clone()),
236 base,
237 head: Some(head),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar238 base_branch: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths239 },
240 )
241 .await?;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.242 Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| (f.path, f.status)).collect()).unwrap_or_default())
243 }
244
245 /// The repository's labels, for their colors, when `payload` has any.
246 async fn label_colors(&self, repo: &Repo, ws: &User, payload: &mut Value) -> Result<()> {
247 if !payload::has_labels(payload) {
248 return Ok(());
249 }
250 let args = ViewArgs { repo: Self::repo_path(repo), number: 0, viewer: Some(ws.clone()), after_seq: 0 };
251 let labels: Outcome<Vec<Label>> = g1t_kit::call(&self.work, "list_labels", &args).await?;
252 if let Outcome::Ok(labels) = labels {
253 payload::color_labels(payload, &labels);
254 }
255 Ok(())
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths256 }
257
258 async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'259 self.branch_head(repo, &repo.default_branch).await
260 }
261
262 /// The commit `branch` points to now.
263 async fn branch_head(&self, repo: &Repo, branch: &str) -> Result<Option<String>> {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths264 g1t_kit::call(
265 &self.repos,
266 "head",
267 &g1t_contracts::repos::HeadArgs {
268 repo_id: repo.id.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'269 branch: branch.to_owned(),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths270 },
271 )
272 .await
273 }
274
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'275 /// The pull request's test merge commit for `head` (repos
276 /// `pull_merge_ref`), made or moved now. A failure to ask is
277 /// unavailable, so that its runs still start, on the head.
278 async fn test_merge(&self, repo: &Repo, pull: &g1t_contracts::work::Pull, head: &str, base_ref: &str) -> PullMergeRef {
279 let asked: Result<PullMergeRef> = g1t_kit::call(
280 &self.repos,
281 "pull_merge_ref",
282 &PullMergeRefArgs {
283 repo_id: repo.id.clone(),
284 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
285 head: head.to_owned(),
286 number: pull.number,
287 target_branch: Some(base_ref.to_owned()),
288 },
289 )
290 .await;
291 asked.unwrap_or_else(|error| PullMergeRef::Unavailable { reason: error.to_string() })
292 }
293
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts294 /// Whether `name` is one of the repository's branches.
295 async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> {
296 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
297 &self.repos,
298 "branches",
299 &g1t_contracts::repos::BranchesArgs {
300 path: Self::repo_path(repo),
301 viewer: Some(ws.clone()),
302 },
303 )
304 .await?;
305 Ok(branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == name))
306 }
307
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths308 fn repo_path(repo: &Repo) -> RepoPath {
309 RepoPath {
310 namespace: repo.namespace.clone(),
311 name: repo.name.clone(),
312 }
313 }
314
315 /// The subject of an event of `kind`, as GitHub's `event_name`.
316 async fn subject(&self, event: &Event, event_name: &str, action: Option<&str>, repo: &Repo, ws: &User, sender: &str) -> Result<Option<Subject>> {
317 let path = Self::repo_path(repo);
318 let data = &event.data;
319 let on_default = |sha: String, payload: Value, title: String, pull: Option<u32>| Subject {
320 source: path.clone(),
321 source_ref: None,
322 git_ref: format!("refs/heads/{}", repo.default_branch),
323 sha,
324 head_ref: None,
325 base_ref: None,
326 pull,
327 filter_ref: format!("refs/heads/{}", repo.default_branch),
328 paths: None,
329 compare: None,
330 payload,
331 title,
332 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'333 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'334 merge: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths335 };
336 let view = |number: u32| ViewArgs {
337 repo: path.clone(),
338 number,
339 viewer: Some(ws.clone()),
340 after_seq: 0,
341 };
342 Ok(match event_name {
343 "push" => {
344 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
345 return Ok(None);
346 };
347 // The merge queue's states run merge_group workflows, not push ones.
348 if git_ref.starts_with("refs/heads/g1t-queue/") {
349 return Ok(None);
350 }
351 let before = data["before"].as_str();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'352 let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths353 let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.354 let email = pusher_email(event.actor.as_deref(), sender);
355 let payload = payload::push(repo, git_ref, before, after, &commits, sender, email.as_deref(), forced);
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths356 Some(Subject {
357 source: path.clone(),
358 source_ref: Some(after.to_owned()),
359 git_ref: git_ref.to_owned(),
360 sha: after.to_owned(),
361 head_ref: None,
362 base_ref: None,
363 pull: None,
364 filter_ref: git_ref.to_owned(),
365 paths: None,
366 compare: Some((before.map(str::to_owned), after.to_owned())),
367 payload,
368 title,
369 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'370 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'371 merge: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths372 })
373 }
Merge branch 'worktree-agent-a3abfcce648e87dca'374 // A branch or tag was made: its own commit, as on GitHub.
375 "create" => {
376 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
377 return Ok(None);
378 };
379 if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() {
380 return Ok(None);
381 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9382 let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
Merge branch 'worktree-agent-a3abfcce648e87dca'383 let payload = json!({
384 "ref": name,
385 "ref_type": ref_type,
386 "master_branch": repo.default_branch,
387 "description": repo.description,
388 "pusher_type": "user",
389 "repository": payload::repository(repo),
390 "sender": payload::user(sender),
391 });
392 Some(Subject {
393 source: path.clone(),
394 source_ref: Some(after.to_owned()),
395 git_ref: git_ref.to_owned(),
396 sha: after.to_owned(),
397 head_ref: None,
398 base_ref: None,
399 pull: None,
400 filter_ref: git_ref.to_owned(),
401 paths: None,
402 compare: None,
403 payload,
404 title: format!("Created {ref_type} {name}"),
405 trusted: true,
406 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'407 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'408 })
409 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9410 // A branch or tag was deleted: on the default branch's latest
411 // commit, as on GitHub, since what was deleted has no commit to
412 // run on now.
413 "delete" => {
414 let Some(git_ref) = data["ref"].as_str() else { return Ok(None) };
415 let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
416 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
417 let payload = json!({
418 "ref": name,
419 "ref_type": ref_type,
420 "pusher_type": "user",
421 "repository": payload::repository(repo),
422 "sender": payload::user(sender),
423 });
424 Some(on_default(sha, payload, format!("Deleted {ref_type} {name}"), None))
425 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths426 "pull_request" | "pull_request_target" | "pull_request_review" => {
427 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9428 // Assignees changed but nobody was added: someone was only
429 // taken off, which is not `assigned`.
430 if action == Some("assigned") && nobody_added(data) {
431 return Ok(None);
432 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths433 let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
434 let Outcome::Ok(detail) = detail else { return Ok(None) };
435 let pull = &detail.pull;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar436 let base_ref = pull.base_branch(&repo.default_branch).to_owned();
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths437 let mut payload = json!({
438 "action": action,
439 "number": pull.number,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar440 "pull_request": payload::pull(repo, pull),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths441 "repository": payload::repository(repo),
442 "sender": payload::user(sender),
443 });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'444 payload::changed(repo, &mut payload, data);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.445 self.label_colors(repo, ws, &mut payload).await?;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'446 // `base.sha` is the base branch's tip now, as GitHub gives
447 // it, not where the two last agreed.
448 if let Some(tip) = self.branch_head(repo, &base_ref).await? {
449 payload["pull_request"]["base"]["sha"] = json!(tip);
450 }
451 // A merge's commit, once it has landed.
452 if pull.status == g1t_contracts::work::PullStatus::Merged
453 && let Some(commit) = data["commit"].as_str()
454 {
455 payload["pull_request"]["merge_commit_sha"] = json!(commit);
456 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths457 if event_name == "pull_request_review" {
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar458 let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory);
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'459 payload["review"] = payload::review(repo, pull, review);
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths460 }
461 let trusted = self.insider(pull.owner(), repo, ws).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'462 // A pull request from outside may wait for approval before
463 // its head's code runs. `pull_request_target` runs the
464 // base's code, and a merged one's run the commit it landed
465 // as, so neither waits.
466 let approval = if event_name != "pull_request_target" && action != Some("closed") {
467 self.approval_needed(repo, pull.owner(), ws).await?
468 } else {
469 None
470 };
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths471 let head_ref = payload::head_ref(pull);
472 if event_name == "pull_request_target" {
473 // In the base's context: its workflows, its head.
474 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
475 let mut subject = on_default(sha, payload, pull.title.clone(), Some(pull.number));
476 subject.head_ref = Some(head_ref);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar477 subject.base_ref = Some(base_ref.clone());
478 subject.filter_ref = format!("refs/heads/{base_ref}");
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths479 subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect());
480 return Ok(Some(subject));
481 }
482 // A merged pull request's run is on the commit it landed as,
483 // in the repository; otherwise on its head, where that is.
484 let landed = match (action, data["commit"].as_str()) {
485 (Some("closed"), Some(commit)) => Some(commit.to_owned()),
486 _ => None,
487 };
488 let sha = match (&landed, data["commit"].as_str(), &pull.head_commit) {
489 (Some(commit), _, _) => commit.clone(),
490 (None, Some(commit), _) => commit.to_owned(),
491 (None, None, Some(head)) => head.clone(),
492 (None, None, None) => return Ok(None),
493 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'494 // Otherwise it builds the head merged into the base's tip,
495 // as on GitHub: the test merge commit at refs/pull/<n>/merge,
496 // in the repository, whose workflows it reads.
497 let merge = match landed {
498 Some(_) => None,
499 None => {
500 let made = self.test_merge(repo, pull, &sha, &base_ref).await;
501 match pull_build(event_name, action, &made) {
502 PullBuild::Merge(merge) => Some(merge),
503 PullBuild::Nothing => {
504 worker::console_log!("actions: pull request #{} conflicts with {base_ref}: {made:?}; no pull_request workflows run", pull.number);
505 return Ok(None);
506 }
507 PullBuild::Head => {
508 worker::console_log!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number);
509 None
510 }
511 }
512 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths513 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'514 if let Some(merge) = &merge {
515 payload["pull_request"]["merge_commit_sha"] = json!(merge);
516 }
517 let (source, source_ref) = match (&landed, &merge) {
518 (Some(_), _) => (path.clone(), sha.clone()),
519 (None, Some(merge)) => (path.clone(), merge.clone()),
520 (None, None) => (pull.fork.clone().unwrap_or_else(|| path.clone()), sha.clone()),
521 };
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths522 Some(Subject {
523 source,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'524 source_ref: Some(source_ref),
525 merge,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths526 git_ref: format!("refs/pull/{}/merge", pull.number),
527 sha,
528 head_ref: Some(head_ref),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar529 base_ref: Some(base_ref.clone()),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths530 pull: Some(pull.number),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar531 // `branches` filters on pull requests name the base.
532 filter_ref: format!("refs/heads/{base_ref}"),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths533 paths: Some(pull.files.iter().map(|f| f.path.clone()).collect()),
534 compare: None,
535 payload,
536 title: pull.title.clone(),
537 trusted,
Merge branch 'worktree-agent-a3abfcce648e87dca'538 approval,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths539 })
540 }
541 "workflow_run" => {
542 // A run of a workflow_run workflow does not start another,
543 // so two such workflows cannot set each other off.
544 if data["event"].as_str() == Some("workflow_run") {
545 return Ok(None);
546 }
547 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
548 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
549 let name = data["workflow"].as_str().unwrap_or_default();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'550 // The followed run's row, for what the event does not carry.
551 let row = match data["runId"].as_str() {
552 Some(id) => self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<crate::plan::RunRow>(None).await?,
553 None => None,
554 };
555 let head_commit = match data["sha"].as_str() {
556 Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head),
557 None => None,
558 };
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.559 // Its pull request, for `pull_requests[]`.
560 let pull = match data["pull"].as_u64() {
561 Some(number) => match g1t_kit::call::<_, Outcome<PullDetail>>(&self.work, "get_pull", &view(number as u32)).await? {
562 Outcome::Ok(detail) => Some(detail.pull),
563 Outcome::Fail(_) => None,
564 },
565 None => None,
566 };
567 let base_sha = match &pull {
568 Some(pull) if pull.fork.is_none() => self.branch_head(repo, pull.base_branch(&repo.default_branch)).await?,
569 _ => None,
570 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'571 let followed = payload::FollowedRun {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.572 pull: pull.as_ref(),
573 base_sha: base_sha.as_deref(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'574 data,
575 workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()),
576 attempt: row.as_ref().map(|r| r.attempt),
577 created_at: row.as_ref().map(|r| r.created_at.as_str()),
578 started_at: row.as_ref().and_then(|r| r.started_at.as_deref()),
579 updated_at: row.as_ref().and_then(|r| r.finished_at.as_deref()),
580 actor: row.as_ref().and_then(|r| r.actor.as_deref()),
581 triggering_actor: row.as_ref().and_then(|r| r.triggering_actor.as_deref()),
582 title: row.as_ref().map(|r| r.title.as_str()),
583 head_commit: head_commit.as_ref(),
584 };
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths585 let payload = json!({
586 "action": "completed",
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'587 "workflow_run": payload::workflow_run(repo, &followed),
588 "workflow": {
589 "id": followed.workflow_id,
590 "node_id": followed.workflow_id,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths591 "name": name,
592 "path": data["path"],
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'593 "state": "active",
594 "html_url": format!("{SITE}/{}/{}/actions", repo.namespace, repo.name),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths595 },
596 "repository": payload::repository(repo),
597 "sender": payload::user(sender),
598 });
599 let mut subject = on_default(sha, payload, format!("After {name}"), None);
600 // Branch filters apply to the branch the followed run was on.
601 subject.filter_ref = format!("refs/heads/{head_branch}");
602 Some(subject)
603 }
604 "issues" | "issue_comment" => {
605 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9606 if action == Some("assigned") && nobody_added(data) {
607 return Ok(None);
608 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths609 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
610 let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
611 let (issue_json, comments, title, on_pull) = match issue {
612 Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false),
613 Outcome::Fail(_) => {
614 let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
615 let Outcome::Ok(detail) = pull else { return Ok(None) };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar616 (payload::pull_as_issue(repo, &detail.pull), detail.comments, detail.pull.title.clone(), true)
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths617 }
618 };
619 let mut payload = json!({
620 "action": action,
621 "issue": issue_json,
622 "repository": payload::repository(repo),
623 "sender": payload::user(sender),
624 });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'625 payload::changed(repo, &mut payload, data);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.626 self.label_colors(repo, ws, &mut payload).await?;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths627 if event_name == "issue_comment" {
628 let comment_id = data["commentId"].as_str();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts629 if action == Some("deleted") {
630 // Gone by now: as the event kept it.
631 match data.get("comment").filter(|kept| kept.is_object()) {
632 Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, on_pull),
633 None => return Ok(None),
634 }
635 } else {
636 let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id);
637 // A new comment is the newest; an edited one must be found.
638 let comment = if action == Some("created") { comment.or(comments.last()) } else { comment };
639 match comment {
640 Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
641 None => return Ok(None),
642 }
643 }
644 // `edited`: what the body was before.
645 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
646 payload["changes"] = changes.clone();
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths647 }
648 }
649 Some(on_default(sha, payload, title, on_pull.then_some(number)))
650 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts651 // A release: on its tag, at the commit the tag named.
652 "release" => {
653 let release = &data["release"];
654 let Some(tag) = data["tagName"].as_str().or_else(|| release["tagName"].as_str()) else { return Ok(None) };
655 let sha = match release["target"].as_str().filter(|target| !target.is_empty()) {
656 Some(target) => target.to_owned(),
657 None => match self.default_head(repo).await? {
658 Some(head) => head,
659 None => return Ok(None),
660 },
661 };
662 let git_ref = format!("refs/tags/{tag}");
663 let mut payload = json!({
664 "action": action,
665 "release": payload::release(repo, release),
666 "repository": payload::repository(repo),
667 "sender": payload::user(sender),
668 });
669 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
670 payload["changes"] = changes.clone();
671 }
672 let name = release["name"].as_str().filter(|name| !name.is_empty()).unwrap_or(tag);
673 Some(Subject {
674 source: path.clone(),
675 source_ref: Some(sha.clone()),
676 git_ref: git_ref.clone(),
677 sha,
678 head_ref: None,
679 base_ref: None,
680 pull: None,
681 filter_ref: git_ref,
682 paths: None,
683 compare: None,
684 payload,
685 title: format!("Release {name} {}", action.unwrap_or("changed")),
686 trusted: true,
687 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'688 merge: None,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts689 })
690 }
691 // A deployment, or a new status of one: at the commit deployed,
692 // on the branch or tag it names (none for a bare commit).
693 "deployment" | "deployment_status" => {
694 let deployment = &data["deployment"];
695 let Some(sha) = deployment["sha"].as_str().filter(|sha| !sha.is_empty()).map(str::to_owned) else { return Ok(None) };
696 let named = deployment["ref"].as_str().unwrap_or_default();
697 let git_ref = if named.is_empty() || named == sha || is_commit(named) {
698 String::new()
699 } else if named.starts_with("refs/") {
700 named.to_owned()
701 } else if self.is_branch(repo, ws, named).await? {
702 format!("refs/heads/{named}")
703 } else {
704 format!("refs/tags/{named}")
705 };
706 let environment = deployment["environment"].as_str().unwrap_or_default();
707 let mut payload = json!({
708 "action": "created",
709 "deployment": payload::deployment(repo, deployment),
710 "repository": payload::repository(repo),
711 "sender": payload::user(sender),
712 });
713 let title = if event_name == "deployment_status" {
714 let status = &data["deploymentStatus"];
715 payload["deployment_status"] = payload::deployment_status(repo, status, deployment);
716 format!("Deployment to {environment}: {}", status["state"].as_str().unwrap_or("changed"))
717 } else {
718 format!("Deployment to {environment}")
719 };
720 Some(Subject {
721 source: path.clone(),
722 source_ref: Some(sha.clone()),
723 filter_ref: git_ref.clone(),
724 git_ref,
725 sha,
726 head_ref: None,
727 base_ref: None,
728 pull: None,
729 paths: None,
730 compare: None,
731 payload,
732 title,
733 trusted: true,
734 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'735 merge: None,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts736 })
737 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths738 _ => None,
739 })
740 }
741
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails742 /// A push keeps the repository's schedules going (`run_schedules`).
743 /// Written at most once a day, and only on scheduled workflows.
744 async fn note_push(&self, repo_id: &str) -> Result<()> {
745 let at = now_ms();
746 self.db
747 .prepare("UPDATE workflows SET pushed_at = ? WHERE repo_id = ? AND crons != '[]' AND (pushed_at IS NULL OR pushed_at < ?)")
748 .bind(&[rfc3339(at).into(), repo_id.into(), rfc3339(at.saturating_sub(24 * 60 * 60 * 1000)).into()])?
749 .run()
750 .await?;
751 Ok(())
752 }
753
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths754 pub async fn on_event(&self, event: &Event) -> Result<()> {
755 let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'756 let mut mapped = github_events(&event.kind);
757 // A new branch or tag is also `create`.
758 if event.kind == "git.push" && event.data["before"].is_null() {
759 mapped.push(("create", None));
760 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths761 let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true);
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails762 if event.kind == "git.push" {
763 self.note_push(repo_id).await?;
764 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths765 if mapped.is_empty() && !pushed_default {
766 return Ok(());
767 }
768 let Some((repo, ws)) = self.repo_by_id(repo_id).await? else { return Ok(()) };
769 if pushed_default {
770 self.sync(&repo, &ws).await?;
771 }
Merge branch 'worktree-agent-a3abfcce648e87dca'772 // What a workflow job's own token did starts no workflows, as on
773 // GitHub, so a workflow cannot set itself off; only
774 // `workflow_dispatch` and `repository_dispatch` do.
775 if let Some(run) = caused_by_job(&event.data) {
776 worker::console_log!("actions: {} {} came from run {run}'s token; no workflows start for it", event.kind, event.id);
777 return Ok(());
778 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths779 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
780 for (event_name, action) in mapped {
781 // Issues and comments start the default branch's workflows,
782 // which the synced table lists: when none listens, nothing is
783 // read from git. Agents make many of these events.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts784 // Deployments' statuses are as frequent (every g1t.page build
785 // reports several), so they look there first too.
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9786 if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete")
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts787 && self.listens(repo_id, event_name).await? == Some(false)
788 {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths789 continue;
790 }
791 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
792 continue;
793 };
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.794 // `[skip ci]` in the head commit's message, as on GitHub.
795 if matches!(event_name, "push" | "pull_request") {
796 let message = match subject.payload["head_commit"]["message"].as_str() {
797 Some(message) => Some(message.to_owned()),
798 None => self.head_message(&subject.source, &ws, &subject.sha).await?,
799 };
800 if message.as_deref().is_some_and(g1t_actions::events::skips_ci) {
801 worker::console_log!("actions: {} {} at {} asks to skip its runs", event.kind, event.id, subject.sha);
802 continue;
Merge branch 'mirroring' into artifacts-mode803 }
804 }
805 // A pull request from a fork reads the fork's files.
806 let read = if subject.source == Self::repo_path(&repo) {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.807 self.read_for(&repo, &ws, subject.source_ref.as_deref()).await?
Merge branch 'mirroring' into artifacts-mode808 } else {
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.809 // And this repository's managed workflows: they are its own,
810 // whatever the fork's files say (managed.rs).
811 let mut read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.812 read.files = self.with_managed(&repo, read.files).await?;
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.813 read
Merge branch 'mirroring' into artifacts-mode814 };
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths815 // A pull request's head runs each workflow once, however many
816 // events say it is there (marked ready, and pushed).
817 let key = match subject.pull {
818 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 timeouts819 // Reopened or made a draft again runs anew, at a head
820 // that may have run before.
821 let phase = match action {
822 Some("closed") => "closed".to_owned(),
823 Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id),
824 _ => "open".to_owned(),
825 };
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths826 format!("{event_name}:{number}:{}:{phase}", subject.sha)
827 }
Merge branch 'worktree-agent-a3abfcce648e87dca'828 _ if event_name == "create" => format!("{}:create", event.id),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths829 _ => event.id.clone(),
830 };
831 self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender)
832 .await?;
833 }
834 Ok(())
835 }
836
837 #[allow(clippy::too_many_arguments)]
838 async fn start_matching(
839 &self,
840 repo: &Repo,
841 ws: &User,
842 read: Read,
843 subject: &mut Subject,
844 event_name: &str,
845 action: Option<&str>,
846 event_key: &str,
847 actor_id: Option<&str>,
848 sender: &str,
849 ) -> Result<()> {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.850 let parsed: Vec<_> = read.files.into_iter().map(|file| (workflow::parse(&file.source), file)).collect();
851 // A push some workflow filters by path is compared before any run
852 // starts, so every run's payload lists what its commit changed.
853 let filters_paths = |(parsed, _): &(std::result::Result<Workflow, _>, _)| {
854 parsed.as_ref().ok().and_then(|w: &Workflow| w.trigger(event_name)).is_some_and(|t| t.paths.is_set())
855 };
856 if event_name == "push" && subject.paths.is_none() && parsed.iter().any(filters_paths) {
857 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
858 subject.changed(self.changed_paths(repo, ws, base, head).await?);
859 }
860 for (parsed, file) in parsed {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths861 let workflow = match parsed {
862 Ok(workflow) => workflow,
863 Err(problem) => {
864 // A push shows a broken workflow as a failed run, as GitHub does.
865 if event_name == "push" && file.source.contains("on") {
866 self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
867 .await?;
868 }
869 continue;
870 }
871 };
872 let Some(trigger) = workflow.trigger(event_name) else { continue };
873 // workflow_run follows the workflows it names.
874 if event_name == "workflow_run" {
875 let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default();
876 if !trigger.workflows.iter().any(|name| name == followed) {
877 continue;
878 }
879 }
880 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
881 continue;
882 }
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.883 // A workspace's required workflow runs whatever its row says.
884 if !file.required && self.disabled(&repo.id, &file.path).await? {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths885 continue;
886 }
887 self.create_run(NewRun {
888 repo: repo.clone(),
889 path: file.path,
890 source: file.source,
891 info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id),
892 workflow,
893 action: action.map(str::to_owned),
894 pull: subject.pull,
895 title: subject.title.clone(),
896 inputs: Map::new(),
897 event_key: event_key.to_owned(),
898 actor_id: actor_id.map(str::to_owned),
899 actor: Some(sender.to_owned()),
900 trusted: subject.trusted,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.901 approval: subject.approval.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'902 head_sha: subject.merge.as_ref().map(|_| subject.sha.clone()),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths903 })
904 .await?;
905 }
906 Ok(())
907 }
908
909 /// Whether the branch, tag and path filters let the event through.
910 async fn passes(&self, repo: &Repo, ws: &User, trigger: &Trigger, subject: &mut Subject, event_name: &str) -> Result<bool> {
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9911 // GitHub takes no filters on these: a workflow with `branches` on
912 // `create` or `delete` still runs for every branch and tag.
913 if unfiltered(event_name) {
914 return Ok(true);
915 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths916 let git_ref = subject.filter_ref.as_str();
917 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
918 // A tag push runs a workflow that filters tags, or filters nothing.
919 if trigger.tags.is_set() {
920 if !trigger.tags.allows(tag) {
921 return Ok(false);
922 }
923 } else if trigger.branches.is_set() {
924 return Ok(false);
925 }
926 // Paths are not checked for tags, as on GitHub.
927 return Ok(true);
928 }
929 let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref);
930 if trigger.branches.is_set() {
931 if !trigger.branches.allows(branch) {
932 return Ok(false);
933 }
934 } else if event_name == "push" && trigger.tags.is_set() {
935 return Ok(false);
936 }
937 if trigger.paths.is_set() {
938 if subject.paths.is_none() {
939 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.940 subject.changed(self.changed_paths(repo, ws, base, head).await?);
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths941 }
942 if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
943 return Ok(false);
944 }
945 }
946 Ok(true)
947 }
948
949 async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> {
950 let row = self
951 .db
952 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
953 .bind(&[repo_id.into(), path.into()])?
954 .first::<WorkflowRow>(None)
955 .await?;
956 Ok(row.is_some_and(|row| row.state == "disabled"))
957 }
958
959 fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo {
960 RunInfo {
961 repository: format!("{}/{}", repo.namespace, repo.name),
962 repository_id: repo.id.clone(),
963 default_branch: repo.default_branch.clone(),
964 event_name: event_name.to_owned(),
965 event: subject.payload.clone(),
966 git_ref: subject.git_ref.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'967 sha: subject.merge.clone().unwrap_or_else(|| subject.sha.clone()),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths968 head_ref: subject.head_ref.clone(),
969 base_ref: subject.base_ref.clone(),
970 actor: sender.to_owned(),
971 actor_id: actor_id.unwrap_or_default().to_owned(),
972 triggering_actor: sender.to_owned(),
973 run_id: String::new(),
974 run_number: 0,
975 run_attempt: 1,
976 workflow: workflow.name.clone().unwrap_or_default(),
977 workflow_path: String::new(),
978 server_url: SITE.to_owned(),
979 api_url: API.to_owned(),
980 }
981 }
982
983 #[allow(clippy::too_many_arguments)]
984 async fn record_invalid(
985 &self,
986 repo: &Repo,
987 path: &str,
988 source: &str,
989 subject: &Subject,
990 event_key: &str,
991 actor_id: Option<&str>,
992 sender: &str,
993 problem: &str,
994 ) -> Result<()> {
995 let row = self.workflow_row(repo, path, path, source).await?;
996 self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
997 }
998
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails999 /// Scheduled workflows that run this minute, on the default branch: at
1000 /// most every five minutes (`cron::Schedule::runs_at`). Not in a
1001 /// repository with no push for [`crate::SCHEDULE_IDLE_MS`], nor for an
1002 /// hour after billing refused one of the workflow's scheduled jobs
1003 /// ([`crate::SCHEDULE_REFUSED_MS`]): no run is made only to be refused.
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1004 pub async fn run_schedules(&self, minute: u64) -> Result<()> {
1005 let rows = self
1006 .db
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1007 .prepare(
1008 "SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL
1009 AND COALESCE(pushed_at, updated_at) >= ? AND (schedule_refused_until IS NULL OR schedule_refused_until <= ?)",
1010 )
1011 .bind(&[rfc3339(minute.saturating_sub(crate::SCHEDULE_IDLE_MS)).into(), rfc3339(minute).into()])?
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1012 .all()
1013 .await?
1014 .results::<WorkflowRow>()?;
1015 for row in rows {
1016 let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1017 let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.runs_at(minute))) else {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1018 continue;
1019 };
1020 let Ok(workflow) = workflow::parse(&row.source) else { continue };
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1021 // Schedules wait while a repository is archived; a deleted one is
1022 // not found.
Merge branch 'mirroring' into artifacts-mode1023 let Some((repo, _ws)) = self
1024 .repo_by_id(&row.repo_id)
1025 .await?
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1026 .filter(|(repo, _)| !repo.archived())
Merge branch 'mirroring' into artifacts-mode1027 else {
1028 continue;
1029 };
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1030 // A managed workflow's row may be out of date while its
1031 // repository waits to be synced again (managed.rs).
1032 if g1t_actions::managed::is_managed(&row.path) && !self.synced(&repo.id).await? {
1033 continue;
1034 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1035 let Some(sha) = self.default_head(&repo).await? else { continue };
1036 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
1037 let mut subject = Subject {
1038 source: Self::repo_path(&repo),
1039 source_ref: None,
1040 git_ref: format!("refs/heads/{}", repo.default_branch),
1041 sha,
1042 head_ref: None,
1043 base_ref: None,
1044 pull: None,
1045 filter_ref: String::new(),
1046 paths: None,
1047 compare: None,
1048 payload,
1049 title: format!("Scheduled: {cron}"),
1050 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1051 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1052 merge: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1053 };
1054 subject.filter_ref = subject.git_ref.clone();
1055 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
1056 self.create_run(NewRun {
1057 repo: repo.clone(),
1058 path: row.path.clone(),
1059 source: row.source.clone(),
1060 workflow,
1061 info,
1062 action: None,
1063 pull: None,
1064 title: subject.title.clone(),
1065 inputs: Map::new(),
1066 event_key: format!("schedule:{minute}"),
1067 actor_id: None,
1068 actor: None,
1069 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1070 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1071 head_sha: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1072 })
1073 .await?;
1074 }
1075 Ok(())
1076 }
1077
1078 /// `dispatch`: someone with the Write role runs a workflow that has
1079 /// `workflow_dispatch`.
1080 pub async fn dispatch(&self, a: DispatchArgs) -> Result<Outcome<WorkflowRun>> {
1081 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
1082 if repo.archived() {
1083 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1084 }
1085 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1086 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1087 };
1088 let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
1089 let full_ref = if git_ref.starts_with("refs/") {
1090 git_ref.clone()
1091 } else {
1092 // A branch if there is one by that name, otherwise a tag.
1093 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
1094 &self.repos,
1095 "branches",
1096 &g1t_contracts::repos::BranchesArgs {
1097 path: Self::repo_path(&repo),
1098 viewer: Some(ws.clone()),
1099 },
1100 )
1101 .await?;
1102 let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
1103 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
1104 };
1105 let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1106 let read = self.read_for(&repo, &ws, Some(&short)).await?;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1107 let Some(sha) = read.head.clone() else {
1108 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
1109 };
1110 // A workflow is named by its file (`build.yml`), its path, or its id
1111 // (`wfl_…`), which stands for the path it was read from.
1112 let by_id = if a.workflow.starts_with("wfl_") {
1113 self.db
1114 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?")
1115 .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])?
1116 .first::<WorkflowRow>(None)
1117 .await?
1118 .map(|row| row.path)
1119 } else {
1120 None
1121 };
1122 let named = by_id.as_deref().unwrap_or(&a.workflow);
1123 let wanted = named.trim_start_matches(".g1t/workflows/");
1124 let Some(file) = read.files.iter().find(|file| {
1125 file.path.rsplit('/').next() == Some(wanted) || file.path == named
1126 }) else {
1127 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
1128 };
1129 let workflow = match workflow::parse(&file.source) {
1130 Ok(workflow) => workflow,
1131 Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
1132 };
1133 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
1134 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
1135 };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91136 // `type: environment` names one of the repository's environments.
1137 let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) {
1138 self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect()
1139 } else {
1140 Vec::new()
1141 };
1142 let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments));
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1143 let payload = json!({
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91144 // As GitHub gives them here: text, `true` and `false` included.
1145 // The `inputs` context keeps booleans and numbers.
1146 "inputs": event_inputs(&inputs),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1147 "ref": full_ref,
1148 "repository": payload::repository(&repo),
1149 "sender": payload::user(&a.actor.username),
1150 "workflow": file.path,
1151 });
1152 let subject = Subject {
1153 source: Self::repo_path(&repo),
1154 source_ref: Some(sha.clone()),
1155 git_ref: full_ref.clone(),
1156 sha,
1157 head_ref: None,
1158 base_ref: None,
1159 pull: None,
1160 filter_ref: full_ref,
1161 paths: None,
1162 compare: None,
1163 payload,
1164 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
1165 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1166 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1167 merge: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1168 };
1169 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1170 let created = self
1171 .create_run(NewRun {
1172 repo: repo.clone(),
1173 path: file.path.clone(),
1174 source: file.source.clone(),
1175 workflow,
1176 info,
1177 action: None,
1178 pull: None,
1179 title: subject.title.clone(),
1180 inputs,
1181 event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
1182 actor_id: Some(a.actor.id.clone()),
1183 actor: Some(a.actor.username.clone()),
1184 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1185 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1186 head_sha: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1187 })
1188 .await?;
1189 match created {
1190 Some(id) => self.run_summary(&id).await,
1191 None => Ok(fail(FailureCode::Conflict, "It did not start.")),
1192 }
1193 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1194
1195 /// `repository_dispatch`: an outside event, by name, starts the default
1196 /// branch's workflows that run `on: repository_dispatch` with that type
1197 /// (or with no `types`). A workflow job's token may send one: this,
1198 /// with `workflow_dispatch`, is how a workflow starts another.
1199 pub async fn repository_dispatch(&self, a: RepositoryDispatchArgs) -> Result<Outcome<u32>> {
1200 let repo = check!(self.may(&a.actor, &a.repo, Capability::Push).await?);
1201 if repo.archived() {
1202 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1203 }
1204 let event_type = a.event_type.trim().to_owned();
1205 if event_type.is_empty() || event_type.chars().count() > 100 {
1206 return Ok(fail(FailureCode::Invalid, "event_type is 1 to 100 characters."));
1207 }
1208 let client_payload = match a.client_payload {
1209 Value::Null => json!({}),
1210 Value::Object(map) if map.len() <= 10 => Value::Object(map),
1211 Value::Object(_) => return Ok(fail(FailureCode::Invalid, "client_payload has at most 10 top-level properties.")),
1212 _ => return Ok(fail(FailureCode::Invalid, "client_payload is a JSON object.")),
1213 };
1214 if serde_json::to_string(&client_payload).map_or(0, |text| text.len()) > 64 * 1024 {
1215 return Ok(fail(FailureCode::Invalid, "client_payload is at most 64 KB."));
1216 }
1217 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1218 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1219 };
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1220 let read = self.read_for(&repo, &ws, Some(&repo.default_branch)).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1221 let Some(sha) = read.head.clone() else {
1222 return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet."));
1223 };
1224 let git_ref = format!("refs/heads/{}", repo.default_branch);
1225 let payload = json!({
1226 "action": event_type,
1227 "branch": repo.default_branch,
1228 "client_payload": client_payload,
1229 "repository": payload::repository(&repo),
1230 "sender": payload::user(&a.actor.username),
1231 });
1232 let key = format!("repository_dispatch:{}", new_id("dsp", now_ms()));
1233 let mut started = 0u32;
1234 for file in read.files {
1235 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1236 let Some(trigger) = workflow.trigger("repository_dispatch") else { continue };
1237 if !trigger.wants_type(Some(&event_type)) || self.disabled(&repo.id, &file.path).await? {
1238 continue;
1239 }
1240 let subject = Subject {
1241 source: Self::repo_path(&repo),
1242 source_ref: Some(sha.clone()),
1243 git_ref: git_ref.clone(),
1244 sha: sha.clone(),
1245 head_ref: None,
1246 base_ref: None,
1247 pull: None,
1248 filter_ref: git_ref.clone(),
1249 paths: None,
1250 compare: None,
1251 payload: payload.clone(),
1252 title: event_type.clone(),
1253 trusted: true,
1254 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1255 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1256 };
1257 let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1258 let created = self
1259 .create_run(NewRun {
1260 repo: repo.clone(),
1261 path: file.path.clone(),
1262 source: file.source.clone(),
1263 workflow,
1264 info,
1265 action: Some(event_type.clone()),
1266 pull: None,
1267 title: subject.title.clone(),
1268 inputs: Map::new(),
1269 event_key: key.clone(),
1270 actor_id: Some(a.actor.id.clone()),
1271 actor: Some(a.actor.username.clone()),
1272 trusted: true,
1273 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1274 head_sha: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1275 })
1276 .await?;
1277 if created.is_some() {
1278 started += 1;
1279 }
1280 }
1281 Ok(Outcome::Ok(started))
1282 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1283}
1284
1285#[derive(serde::Deserialize)]
1286#[serde(rename_all = "camelCase")]
1287pub struct MergeGroupArgs {
1288 pub repo_id: String,
1289 pub entry: String,
1290 pub sha: String,
1291 pub head_ref: String,
1292 #[serde(default)]
1293 pub base_sha: Option<String>,
1294 pub number: u32,
1295 #[serde(default)]
1296 pub ahead: Vec<u32>,
1297}
1298
1299impl Actions {
1300 /// `merge_group`: the merge queue built a state and its checks passed.
1301 /// Starts the workflows that run `on: merge_group` on it, as GitHub's
1302 /// queue does, and says how many started; the queue waits for their
1303 /// statuses on that commit.
1304 pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
1305 let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
1306 return Ok(Outcome::Ok(json!({ "runs": 0 })));
1307 };
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1308 let mut read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
1309 // A required workflow's check holds the queue too (managed.rs).
1310 read.files = self.with_managed(&repo, read.files).await?;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1311 let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
1312 let payload = json!({
1313 "action": "checks_requested",
1314 "merge_group": {
1315 "head_sha": a.sha,
1316 "head_ref": a.head_ref,
1317 "base_sha": a.base_sha,
1318 "base_ref": format!("refs/heads/{}", repo.default_branch),
1319 "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
1320 },
1321 "repository": payload::repository(&repo),
1322 "sender": payload::user(&repo.namespace),
1323 });
1324 let mut started = 0u32;
1325 for file in read.files {
1326 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1327 let Some(trigger) = workflow.trigger("merge_group") else { continue };
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1328 if !trigger.wants_type(Some("checks_requested")) || (!file.required && self.disabled(&repo.id, &file.path).await?) {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1329 continue;
1330 }
1331 // Branch filters on merge_group name the branch it merges into.
1332 if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
1333 continue;
1334 }
1335 let ahead = if a.ahead.is_empty() {
1336 String::new()
1337 } else {
1338 format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
1339 };
1340 let subject = Subject {
1341 source: Self::repo_path(&repo),
1342 source_ref: Some(a.sha.clone()),
1343 git_ref: a.head_ref.clone(),
1344 sha: a.sha.clone(),
1345 head_ref: None,
1346 base_ref: Some(repo.default_branch.clone()),
1347 pull: Some(a.number),
1348 filter_ref: format!("refs/heads/{}", repo.default_branch),
1349 paths: None,
1350 compare: None,
1351 payload: payload.clone(),
1352 title: format!("Merge queue: #{}{ahead}", a.number),
1353 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1354 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1355 merge: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1356 };
1357 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
1358 let created = self
1359 .create_run(NewRun {
1360 repo: repo.clone(),
1361 path: file.path.clone(),
1362 source: file.source.clone(),
1363 workflow,
1364 info,
1365 action: Some("checks_requested".to_owned()),
1366 pull: Some(a.number),
1367 title: subject.title.clone(),
1368 inputs: Map::new(),
1369 event_key: format!("merge_group:{}:{}", a.entry, a.sha),
1370 actor_id: None,
1371 actor: None,
1372 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1373 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1374 head_sha: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1375 })
1376 .await?;
1377 if created.is_some() {
1378 started += 1;
1379 }
1380 }
1381 Ok(Outcome::Ok(json!({ "runs": started })))
1382 }
1383}
1384
1385/// The inputs of a manual run: what was given, checked against the
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91386/// workflow's declared inputs, with their defaults filled in. An input the
1387/// workflow does not declare is refused, as GitHub refuses it.
1388/// `environments` are the repository's, for `type: environment`.
1389fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> {
1390 let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect();
1391 if !unexpected.is_empty() {
1392 let names = serde_json::to_string(&unexpected).unwrap_or_default();
1393 let declared = if trigger.inputs.is_empty() {
1394 "The workflow declares no inputs.".to_owned()
1395 } else {
1396 format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", "))
1397 };
1398 return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}"));
1399 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1400 let mut inputs = Map::new();
1401 for (name, spec) in &trigger.inputs {
1402 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
1403 let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
1404 let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
1405 let value = match value {
1406 Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
1407 Some(Value::Null) | None => match kind {
1408 "boolean" => Value::Bool(false),
1409 _ => Value::String(String::new()),
1410 },
1411 Some(value) => match kind {
1412 "boolean" => Value::Bool(match &value {
1413 Value::Bool(flag) => *flag,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91414 Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true,
1415 Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false,
1416 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1417 }),
1418 "number" => match &value {
1419 Value::Number(_) => value,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91420 Value::String(text) => match number(text) {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1421 Some(number) => Value::Number(number),
1422 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1423 },
1424 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1425 },
1426 "choice" => {
1427 let text = g1t_actions::expr::to_text(&value);
1428 let options: Vec<String> =
1429 spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
1430 if !options.is_empty() && !options.contains(&text) {
1431 return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
1432 }
1433 Value::String(text)
1434 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91435 "environment" => {
1436 let text = g1t_actions::expr::to_text(&value);
1437 let wanted = text.trim().to_ascii_lowercase();
1438 if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) {
1439 return fail(
1440 FailureCode::Invalid,
1441 format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()),
1442 );
1443 }
1444 Value::String(text)
1445 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1446 _ => Value::String(g1t_actions::expr::to_text(&value)),
1447 },
1448 };
1449 inputs.insert(name.clone(), value);
1450 }
1451 Outcome::Ok(inputs)
1452}
1453
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91454/// A number input given as text: a whole number stays one.
1455fn number(text: &str) -> Option<serde_json::Number> {
1456 let text = text.trim();
1457 match text.parse::<i64>() {
1458 Ok(whole) => Some(whole.into()),
1459 Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64),
1460 }
1461}
1462
1463/// A manual run's inputs as `github.event.inputs` gives them: every one as
1464/// text, as GitHub's `workflow_dispatch` payload has them.
1465fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> {
1466 inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect()
1467}
1468
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472 use g1t_contracts::work::{Pull, g1t_author};
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1473
1474 #[test]
1475 fn every_event_a_workflow_runs_on_is_sent_to_this_queue() {
1476 for kind in g1t_contracts::webhooks::EVENT_TYPES {
1477 if !github_events(kind).is_empty() {
1478 assert!(
1479 g1t_contracts::subscribers::routed("SUBSCRIBER_ACTIONS", kind),
1480 "{kind} starts workflows but is not routed to actions (g1t_contracts::subscribers)"
1481 );
1482 }
1483 }
1484 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1485 use g1t_contracts::{Membership, PrincipalKind};
1486
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1487 #[test]
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1488 fn a_pushers_email_is_their_noreply_address() {
1489 assert_eq!(pusher_email(Some("usr_01j9zq4m8x7k2v5n3b6c1d0efg"), "Ada").as_deref(), Some("6c1d0efg+ada@users.noreply.g1t.sh"));
1490 assert_eq!(pusher_email(Some(AGENT_ID), AGENT_NAME).as_deref(), Some(g1t_contracts::system::EMAIL));
1491 assert_eq!(pusher_email(Some("wsp_1"), "acme"), None);
1492 assert_eq!(pusher_email(None, "acme"), None);
1493 }
1494
1495 #[test]
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1496 fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() {
1497 let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
1498 let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
1499 let unavailable = PullMergeRef::Unavailable { reason: "too large".into() };
1500 assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into()));
1501 assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into()));
1502 // GitHub runs no pull_request workflow while it conflicts.
1503 for action in ["opened", "synchronize", "reopened", "edited"] {
1504 assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing);
1505 }
1506 // Closing it, or reviewing it, still runs, on the head.
1507 assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head);
1508 assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head);
1509 // What g1t could not merge itself runs on the head.
1510 assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head);
1511 }
1512
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1513 fn repo() -> Repo {
1514 serde_json::from_value(json!({
1515 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
1516 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
1517 }))
1518 .unwrap()
1519 }
1520
1521 fn person(id: &str, username: &str) -> User {
1522 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
1523 }
1524
1525 fn made_for(asker: User) -> Pull {
1526 serde_json::from_value(json!({
1527 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
1528 "agent": "g1t", "runtime": "hosted", "status": "open",
1529 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
1530 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
1531 "supersededBy": null, "checkStatus": null,
1532 "author": g1t_author(), "requestedBy": asker,
1533 "createdAt": "", "updatedAt": ""
1534 }))
1535 .unwrap()
1536 }
1537
1538 #[test]
1539 fn g1t_s_change_for_someone_is_trusted_as_they_are() {
1540 // Stored people carry no memberships, so identity is asked about
1541 // them; being g1t's change gives it nothing more.
1542 let pull = made_for(person("usr_2", "ana"));
1543 assert!(!trusted_outright(pull.owner(), &repo()));
1544 // Someone known to be able to push is trusted at once.
1545 let mut member = person("usr_1", "syntaqx");
1546 member.workspaces.push(Membership::member("acme"));
1547 let pull = made_for(member);
1548 assert!(trusted_outright(pull.owner(), &repo()));
1549 }
1550
1551 #[test]
1552 fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() {
1553 let pull = made_for(person("usr_1", "syntaqx"));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1554 let event = payload::pull(&repo(), &pull);
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1555 assert_eq!(event["user"]["login"], "g1t");
1556 assert_eq!(event["user"]["type"], "Bot");
1557 assert_eq!(event["requested_by"]["login"], "syntaqx");
1558 assert_eq!(event["requested_by"]["type"], "User");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1559 let as_issue = payload::pull_as_issue(&repo(), &pull);
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1560 assert_eq!(as_issue["user"]["login"], "g1t");
1561 assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
1562 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1563
1564 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1565 fn releases_deployments_and_deleted_comments_read_as_githubs() {
1566 let release = payload::release(
1567 &repo(),
1568 &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false,
1569 "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }),
1570 );
1571 assert_eq!(release["tag_name"], "v1.2.0");
1572 assert_eq!(release["name"], "v1.2.0");
1573 assert_eq!(release["prerelease"], true);
1574 assert_eq!(release["author"]["login"], "ana");
1575 assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0");
1576 let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana",
1577 "production_environment": false, "created_at": "t", "updated_at": "t" });
1578 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);
1579 assert_eq!(status["state"], "success");
1580 assert_eq!(status["environment"], "staging");
1581 assert_eq!(status["environment_url"], "https://s.example");
1582 assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1583 let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1584 assert_eq!(gone["user"]["login"], "bo");
1585 assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1586 assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
1587 assert!(!is_commit("main"));
1588 }
1589
1590 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1591 fn a_pull_request_names_its_own_base_labels_and_milestone() {
1592 let mut pull = made_for(person("usr_1", "syntaqx"));
1593 let event = payload::pull(&repo(), &pull);
1594 assert_eq!(event["base"]["ref"], repo().default_branch);
1595 pull.base = Some("release/1.x".into());
1596 pull.labels = vec!["bug".into()];
1597 pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() });
1598 let event = payload::pull(&repo(), &pull);
1599 assert_eq!(event["base"]["ref"], "release/1.x");
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1600 assert_eq!(event["labels"][0]["name"], "bug");
1601 assert!(event["labels"][0]["id"].is_u64());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1602 assert_eq!(event["milestone"]["title"], "1.1");
1603 let mut labeled = serde_json::json!({ "action": "labeled" });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1604 payload::changed(&repo(), &mut labeled, &serde_json::json!({ "label": { "name": "bug", "color": "d73a4a" } }));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1605 assert_eq!(labeled["label"]["color"], "d73a4a");
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91606 let mut assigned = serde_json::json!({ "action": "assigned" });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1607 payload::changed(&repo(), &mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91608 assert_eq!(assigned["assignee"]["login"], "bo");
1609 assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] })));
1610 assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
1611 }
1612
1613 fn dispatchable(yaml: &str) -> Trigger {
1614 workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone()
1615 }
1616
1617 fn given(value: Value) -> Map<String, Value> {
1618 value.as_object().unwrap().clone()
1619 }
1620
1621 const DISPATCH: &str = "on:
1622 workflow_dispatch:
1623 inputs:
1624 dry-run: { type: boolean, default: false }
1625 count: { type: number, default: 2 }
1626 target: { type: environment }
1627 level: { type: choice, options: [low, high], default: low }
1628 note: { required: false }
1629jobs:
1630 a:
1631 runs-on: x
1632 steps: [{ run: 'true' }]";
1633
1634 #[test]
1635 fn an_input_the_workflow_does_not_declare_is_refused() {
1636 let trigger = dispatchable(DISPATCH);
1637 let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else {
1638 panic!("an undeclared input was taken");
1639 };
1640 assert_eq!(failure.code, FailureCode::Invalid);
1641 assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message);
1642 assert!(failure.message.contains("`dry-run`"));
1643 let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]");
1644 let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() };
1645 assert!(failure.message.contains("declares no inputs"));
1646 assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty()));
1647 }
1648
1649 #[test]
1650 fn inputs_are_typed_and_the_event_has_them_as_text() {
1651 let trigger = dispatchable(DISPATCH);
1652 let environments = vec!["staging".to_owned(), "production".to_owned()];
1653 let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments)
1654 else {
1655 panic!("refused");
1656 };
1657 assert_eq!(inputs["dry-run"], json!(true));
1658 assert_eq!(inputs["count"], json!(3));
1659 assert_eq!(inputs["level"], json!("low"));
1660 assert_eq!(inputs["note"], json!(""));
1661 let event = event_inputs(&inputs);
1662 assert_eq!(event["dry-run"], json!("true"));
1663 assert_eq!(event["count"], json!("3"));
1664 assert_eq!(event["target"], json!("Staging"));
1665 let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() };
1666 assert_eq!(event_inputs(&defaults)["dry-run"], json!("false"));
1667 assert_eq!(defaults["count"], json!(2));
1668 let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) {
1669 Outcome::Fail(failure) => failure.message,
1670 Outcome::Ok(_) => panic!("taken"),
1671 };
1672 assert!(refused(json!({ "target": "qa" })).contains("none called `qa`"));
1673 assert!(refused(json!({ "dry-run": "yes" })).contains("true or false"));
1674 assert!(refused(json!({ "count": "many" })).contains("is a number"));
1675 assert!(refused(json!({ "level": "mid" })).contains("low, high"));
1676 }
1677
1678 #[test]
1679 fn create_and_delete_take_no_filters_and_name_their_ref() {
1680 assert!(unfiltered("create") && unfiltered("delete"));
1681 assert!(!unfiltered("push") && !unfiltered("pull_request"));
1682 assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x")));
1683 assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0")));
1684 assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None);
1685 assert_eq!(branch_or_tag("refs/notes/commits"), None);
1686 // A workflow can name `delete`. It takes no filters, as GitHub's
1687 // schema says, so writing one is refused when the workflow is read.
1688 assert!(workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").is_err());
1689 let workflow = workflow::parse("on:\n delete:\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1690 let trigger = workflow.trigger("delete").unwrap();
1691 assert!(trigger.wants_type(None));
1692 assert!(!workflow.notes.iter().any(|note| note.severity == workflow::Severity::Unsupported));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1693 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1694}

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