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,695 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.

Docs: the inbox, what lands in it and why, its tabs and actions1//! 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};
Docs: the inbox, what lands in it and why, its tabs and actions10use 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};
Docs: the inbox, what lands in it and why, its tabs and actions13use g1t_contracts::{FailureCode, Outcome, User, new_id};
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails14use g1t_contracts::time::rfc3339;
Docs: the inbox, what lands in it and why, its tabs and actions15use 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
Docs: the inbox, what lands in it and why, its tabs and actions27/// 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>,
Docs: the inbox, what lands in it and why, its tabs and actions54}
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
Docs: the inbox, what lands in it and why, its tabs and actions130/// 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.
Docs: the inbox, what lands in it and why, its tabs and actions132fn 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)
Docs: the inbox, what lands in it and why, its tabs and actions134}
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)> {
Docs: the inbox, what lands in it and why, its tabs and actions184 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,
Docs: the inbox, what lands in it and why, its tabs and actions195 },
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 }
Docs: the inbox, what lands in it and why, its tabs and actions206 }
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))
Docs: the inbox, what lands in it and why, its tabs and actions210 }
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)>> {
Docs: the inbox, what lands in it and why, its tabs and actions230 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,
Merge branch 'worktree-agent-a432c4ed9deb834b1'239 paths: None,
Docs: the inbox, what lands in it and why, its tabs and actions240 },
241 )
242 .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.243 Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| (f.path, f.status)).collect()).unwrap_or_default())
244 }
245
246 /// The repository's labels, for their colors, when `payload` has any.
247 async fn label_colors(&self, repo: &Repo, ws: &User, payload: &mut Value) -> Result<()> {
248 if !payload::has_labels(payload) {
249 return Ok(());
250 }
251 let args = ViewArgs { repo: Self::repo_path(repo), number: 0, viewer: Some(ws.clone()), after_seq: 0 };
252 let labels: Outcome<Vec<Label>> = g1t_kit::call(&self.work, "list_labels", &args).await?;
253 if let Outcome::Ok(labels) = labels {
254 payload::color_labels(payload, &labels);
255 }
256 Ok(())
Docs: the inbox, what lands in it and why, its tabs and actions257 }
258
259 async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'260 self.branch_head(repo, &repo.default_branch).await
261 }
262
263 /// The commit `branch` points to now.
264 async fn branch_head(&self, repo: &Repo, branch: &str) -> Result<Option<String>> {
Docs: the inbox, what lands in it and why, its tabs and actions265 g1t_kit::call(
266 &self.repos,
267 "head",
268 &g1t_contracts::repos::HeadArgs {
269 repo_id: repo.id.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'270 branch: branch.to_owned(),
Docs: the inbox, what lands in it and why, its tabs and actions271 },
272 )
273 .await
274 }
275
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'276 /// The pull request's test merge commit for `head` (repos
277 /// `pull_merge_ref`), made or moved now. A failure to ask is
278 /// unavailable, so that its runs still start, on the head.
279 async fn test_merge(&self, repo: &Repo, pull: &g1t_contracts::work::Pull, head: &str, base_ref: &str) -> PullMergeRef {
280 let asked: Result<PullMergeRef> = g1t_kit::call(
281 &self.repos,
282 "pull_merge_ref",
283 &PullMergeRefArgs {
284 repo_id: repo.id.clone(),
285 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
286 head: head.to_owned(),
287 number: pull.number,
288 target_branch: Some(base_ref.to_owned()),
289 },
290 )
291 .await;
292 asked.unwrap_or_else(|error| PullMergeRef::Unavailable { reason: error.to_string() })
293 }
294
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts295 /// Whether `name` is one of the repository's branches.
296 async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> {
297 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
298 &self.repos,
299 "branches",
300 &g1t_contracts::repos::BranchesArgs {
301 path: Self::repo_path(repo),
302 viewer: Some(ws.clone()),
303 },
304 )
305 .await?;
306 Ok(branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == name))
307 }
308
Docs: the inbox, what lands in it and why, its tabs and actions309 fn repo_path(repo: &Repo) -> RepoPath {
310 RepoPath {
311 namespace: repo.namespace.clone(),
312 name: repo.name.clone(),
313 }
314 }
315
316 /// The subject of an event of `kind`, as GitHub's `event_name`.
317 async fn subject(&self, event: &Event, event_name: &str, action: Option<&str>, repo: &Repo, ws: &User, sender: &str) -> Result<Option<Subject>> {
318 let path = Self::repo_path(repo);
319 let data = &event.data;
320 let on_default = |sha: String, payload: Value, title: String, pull: Option<u32>| Subject {
321 source: path.clone(),
322 source_ref: None,
323 git_ref: format!("refs/heads/{}", repo.default_branch),
324 sha,
325 head_ref: None,
326 base_ref: None,
327 pull,
328 filter_ref: format!("refs/heads/{}", repo.default_branch),
329 paths: None,
330 compare: None,
331 payload,
332 title,
333 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'334 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'335 merge: None,
Docs: the inbox, what lands in it and why, its tabs and actions336 };
337 let view = |number: u32| ViewArgs {
338 repo: path.clone(),
339 number,
340 viewer: Some(ws.clone()),
341 after_seq: 0,
342 };
343 Ok(match event_name {
344 "push" => {
345 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
346 return Ok(None);
347 };
348 // The merge queue's states run merge_group workflows, not push ones.
349 if git_ref.starts_with("refs/heads/g1t-queue/") {
350 return Ok(None);
351 }
352 let before = data["before"].as_str();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'353 let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?;
Docs: the inbox, what lands in it and why, its tabs and actions354 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.355 let email = pusher_email(event.actor.as_deref(), sender);
356 let payload = payload::push(repo, git_ref, before, after, &commits, sender, email.as_deref(), forced);
Docs: the inbox, what lands in it and why, its tabs and actions357 Some(Subject {
358 source: path.clone(),
359 source_ref: Some(after.to_owned()),
360 git_ref: git_ref.to_owned(),
361 sha: after.to_owned(),
362 head_ref: None,
363 base_ref: None,
364 pull: None,
365 filter_ref: git_ref.to_owned(),
366 paths: None,
367 compare: Some((before.map(str::to_owned), after.to_owned())),
368 payload,
369 title,
370 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'371 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'372 merge: None,
Docs: the inbox, what lands in it and why, its tabs and actions373 })
374 }
Merge branch 'worktree-agent-a3abfcce648e87dca'375 // A branch or tag was made: its own commit, as on GitHub.
376 "create" => {
377 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
378 return Ok(None);
379 };
380 if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() {
381 return Ok(None);
382 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9383 let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
Merge branch 'worktree-agent-a3abfcce648e87dca'384 let payload = json!({
385 "ref": name,
386 "ref_type": ref_type,
387 "master_branch": repo.default_branch,
388 "description": repo.description,
389 "pusher_type": "user",
390 "repository": payload::repository(repo),
391 "sender": payload::user(sender),
392 });
393 Some(Subject {
394 source: path.clone(),
395 source_ref: Some(after.to_owned()),
396 git_ref: git_ref.to_owned(),
397 sha: after.to_owned(),
398 head_ref: None,
399 base_ref: None,
400 pull: None,
401 filter_ref: git_ref.to_owned(),
402 paths: None,
403 compare: None,
404 payload,
405 title: format!("Created {ref_type} {name}"),
406 trusted: true,
407 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'408 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'409 })
410 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9411 // A branch or tag was deleted: on the default branch's latest
412 // commit, as on GitHub, since what was deleted has no commit to
413 // run on now.
414 "delete" => {
415 let Some(git_ref) = data["ref"].as_str() else { return Ok(None) };
416 let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
417 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
418 let payload = json!({
419 "ref": name,
420 "ref_type": ref_type,
421 "pusher_type": "user",
422 "repository": payload::repository(repo),
423 "sender": payload::user(sender),
424 });
425 Some(on_default(sha, payload, format!("Deleted {ref_type} {name}"), None))
426 }
Docs: the inbox, what lands in it and why, its tabs and actions427 "pull_request" | "pull_request_target" | "pull_request_review" => {
428 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-7nm8r9429 // Assignees changed but nobody was added: someone was only
430 // taken off, which is not `assigned`.
431 if action == Some("assigned") && nobody_added(data) {
432 return Ok(None);
433 }
Docs: the inbox, what lands in it and why, its tabs and actions434 let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
435 let Outcome::Ok(detail) = detail else { return Ok(None) };
436 let pull = &detail.pull;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar437 let base_ref = pull.base_branch(&repo.default_branch).to_owned();
Docs: the inbox, what lands in it and why, its tabs and actions438 let mut payload = json!({
439 "action": action,
440 "number": pull.number,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar441 "pull_request": payload::pull(repo, pull),
Docs: the inbox, what lands in it and why, its tabs and actions442 "repository": payload::repository(repo),
443 "sender": payload::user(sender),
444 });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'445 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.446 self.label_colors(repo, ws, &mut payload).await?;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'447 // `base.sha` is the base branch's tip now, as GitHub gives
448 // it, not where the two last agreed.
449 if let Some(tip) = self.branch_head(repo, &base_ref).await? {
450 payload["pull_request"]["base"]["sha"] = json!(tip);
451 }
452 // A merge's commit, once it has landed.
453 if pull.status == g1t_contracts::work::PullStatus::Merged
454 && let Some(commit) = data["commit"].as_str()
455 {
456 payload["pull_request"]["merge_commit_sha"] = json!(commit);
457 }
Docs: the inbox, what lands in it and why, its tabs and actions458 if event_name == "pull_request_review" {
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar459 let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory);
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'460 payload["review"] = payload::review(repo, pull, review);
Docs: the inbox, what lands in it and why, its tabs and actions461 }
462 let trusted = self.insider(pull.owner(), repo, ws).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'463 // A pull request from outside may wait for approval before
464 // its head's code runs. `pull_request_target` runs the
465 // base's code, and a merged one's run the commit it landed
466 // as, so neither waits.
467 let approval = if event_name != "pull_request_target" && action != Some("closed") {
468 self.approval_needed(repo, pull.owner(), ws).await?
469 } else {
470 None
471 };
Docs: the inbox, what lands in it and why, its tabs and actions472 let head_ref = payload::head_ref(pull);
473 if event_name == "pull_request_target" {
474 // In the base's context: its workflows, its head.
475 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
476 let mut subject = on_default(sha, payload, pull.title.clone(), Some(pull.number));
477 subject.head_ref = Some(head_ref);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar478 subject.base_ref = Some(base_ref.clone());
479 subject.filter_ref = format!("refs/heads/{base_ref}");
Docs: the inbox, what lands in it and why, its tabs and actions480 subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect());
481 return Ok(Some(subject));
482 }
483 // A merged pull request's run is on the commit it landed as,
484 // in the repository; otherwise on its head, where that is.
485 let landed = match (action, data["commit"].as_str()) {
486 (Some("closed"), Some(commit)) => Some(commit.to_owned()),
487 _ => None,
488 };
489 let sha = match (&landed, data["commit"].as_str(), &pull.head_commit) {
490 (Some(commit), _, _) => commit.clone(),
491 (None, Some(commit), _) => commit.to_owned(),
492 (None, None, Some(head)) => head.clone(),
493 (None, None, None) => return Ok(None),
494 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'495 // Otherwise it builds the head merged into the base's tip,
496 // as on GitHub: the test merge commit at refs/pull/<n>/merge,
497 // in the repository, whose workflows it reads.
498 let merge = match landed {
499 Some(_) => None,
500 None => {
501 let made = self.test_merge(repo, pull, &sha, &base_ref).await;
502 match pull_build(event_name, action, &made) {
503 PullBuild::Merge(merge) => Some(merge),
504 PullBuild::Nothing => {
505 worker::console_log!("actions: pull request #{} conflicts with {base_ref}: {made:?}; no pull_request workflows run", pull.number);
506 return Ok(None);
507 }
508 PullBuild::Head => {
509 worker::console_log!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number);
510 None
511 }
512 }
513 }
Docs: the inbox, what lands in it and why, its tabs and actions514 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'515 if let Some(merge) = &merge {
516 payload["pull_request"]["merge_commit_sha"] = json!(merge);
517 }
518 let (source, source_ref) = match (&landed, &merge) {
519 (Some(_), _) => (path.clone(), sha.clone()),
520 (None, Some(merge)) => (path.clone(), merge.clone()),
521 (None, None) => (pull.fork.clone().unwrap_or_else(|| path.clone()), sha.clone()),
522 };
Docs: the inbox, what lands in it and why, its tabs and actions523 Some(Subject {
524 source,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'525 source_ref: Some(source_ref),
526 merge,
Docs: the inbox, what lands in it and why, its tabs and actions527 git_ref: format!("refs/pull/{}/merge", pull.number),
528 sha,
529 head_ref: Some(head_ref),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar530 base_ref: Some(base_ref.clone()),
Docs: the inbox, what lands in it and why, its tabs and actions531 pull: Some(pull.number),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar532 // `branches` filters on pull requests name the base.
533 filter_ref: format!("refs/heads/{base_ref}"),
Docs: the inbox, what lands in it and why, its tabs and actions534 paths: Some(pull.files.iter().map(|f| f.path.clone()).collect()),
535 compare: None,
536 payload,
537 title: pull.title.clone(),
538 trusted,
Merge branch 'worktree-agent-a3abfcce648e87dca'539 approval,
Docs: the inbox, what lands in it and why, its tabs and actions540 })
541 }
542 "workflow_run" => {
543 // A run of a workflow_run workflow does not start another,
544 // so two such workflows cannot set each other off.
545 if data["event"].as_str() == Some("workflow_run") {
546 return Ok(None);
547 }
548 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
549 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
550 let name = data["workflow"].as_str().unwrap_or_default();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'551 // The followed run's row, for what the event does not carry.
552 let row = match data["runId"].as_str() {
553 Some(id) => self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<crate::plan::RunRow>(None).await?,
554 None => None,
555 };
556 let head_commit = match data["sha"].as_str() {
557 Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head),
558 None => None,
559 };
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.560 // Its pull request, for `pull_requests[]`.
561 let pull = match data["pull"].as_u64() {
562 Some(number) => match g1t_kit::call::<_, Outcome<PullDetail>>(&self.work, "get_pull", &view(number as u32)).await? {
563 Outcome::Ok(detail) => Some(detail.pull),
564 Outcome::Fail(_) => None,
565 },
566 None => None,
567 };
568 let base_sha = match &pull {
569 Some(pull) if pull.fork.is_none() => self.branch_head(repo, pull.base_branch(&repo.default_branch)).await?,
570 _ => None,
571 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'572 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.573 pull: pull.as_ref(),
574 base_sha: base_sha.as_deref(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'575 data,
576 workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()),
577 attempt: row.as_ref().map(|r| r.attempt),
578 created_at: row.as_ref().map(|r| r.created_at.as_str()),
579 started_at: row.as_ref().and_then(|r| r.started_at.as_deref()),
580 updated_at: row.as_ref().and_then(|r| r.finished_at.as_deref()),
581 actor: row.as_ref().and_then(|r| r.actor.as_deref()),
582 triggering_actor: row.as_ref().and_then(|r| r.triggering_actor.as_deref()),
583 title: row.as_ref().map(|r| r.title.as_str()),
584 head_commit: head_commit.as_ref(),
585 };
Docs: the inbox, what lands in it and why, its tabs and actions586 let payload = json!({
587 "action": "completed",
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'588 "workflow_run": payload::workflow_run(repo, &followed),
589 "workflow": {
590 "id": followed.workflow_id,
591 "node_id": followed.workflow_id,
Docs: the inbox, what lands in it and why, its tabs and actions592 "name": name,
593 "path": data["path"],
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'594 "state": "active",
595 "html_url": format!("{SITE}/{}/{}/actions", repo.namespace, repo.name),
Docs: the inbox, what lands in it and why, its tabs and actions596 },
597 "repository": payload::repository(repo),
598 "sender": payload::user(sender),
599 });
600 let mut subject = on_default(sha, payload, format!("After {name}"), None);
601 // Branch filters apply to the branch the followed run was on.
602 subject.filter_ref = format!("refs/heads/{head_branch}");
603 Some(subject)
604 }
605 "issues" | "issue_comment" => {
606 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-7nm8r9607 if action == Some("assigned") && nobody_added(data) {
608 return Ok(None);
609 }
Docs: the inbox, what lands in it and why, its tabs and actions610 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
611 let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
612 let (issue_json, comments, title, on_pull) = match issue {
613 Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false),
614 Outcome::Fail(_) => {
615 let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
616 let Outcome::Ok(detail) = pull else { return Ok(None) };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar617 (payload::pull_as_issue(repo, &detail.pull), detail.comments, detail.pull.title.clone(), true)
Docs: the inbox, what lands in it and why, its tabs and actions618 }
619 };
620 let mut payload = json!({
621 "action": action,
622 "issue": issue_json,
623 "repository": payload::repository(repo),
624 "sender": payload::user(sender),
625 });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'626 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.627 self.label_colors(repo, ws, &mut payload).await?;
Docs: the inbox, what lands in it and why, its tabs and actions628 if event_name == "issue_comment" {
629 let comment_id = data["commentId"].as_str();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts630 if action == Some("deleted") {
631 // Gone by now: as the event kept it.
632 match data.get("comment").filter(|kept| kept.is_object()) {
633 Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, on_pull),
634 None => return Ok(None),
635 }
636 } else {
637 let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id);
638 // A new comment is the newest; an edited one must be found.
639 let comment = if action == Some("created") { comment.or(comments.last()) } else { comment };
640 match comment {
641 Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
642 None => return Ok(None),
643 }
644 }
645 // `edited`: what the body was before.
646 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
647 payload["changes"] = changes.clone();
Docs: the inbox, what lands in it and why, its tabs and actions648 }
649 }
650 Some(on_default(sha, payload, title, on_pull.then_some(number)))
651 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts652 // A release: on its tag, at the commit the tag named.
653 "release" => {
654 let release = &data["release"];
655 let Some(tag) = data["tagName"].as_str().or_else(|| release["tagName"].as_str()) else { return Ok(None) };
656 let sha = match release["target"].as_str().filter(|target| !target.is_empty()) {
657 Some(target) => target.to_owned(),
658 None => match self.default_head(repo).await? {
659 Some(head) => head,
660 None => return Ok(None),
661 },
662 };
663 let git_ref = format!("refs/tags/{tag}");
664 let mut payload = json!({
665 "action": action,
666 "release": payload::release(repo, release),
667 "repository": payload::repository(repo),
668 "sender": payload::user(sender),
669 });
670 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
671 payload["changes"] = changes.clone();
672 }
673 let name = release["name"].as_str().filter(|name| !name.is_empty()).unwrap_or(tag);
674 Some(Subject {
675 source: path.clone(),
676 source_ref: Some(sha.clone()),
677 git_ref: git_ref.clone(),
678 sha,
679 head_ref: None,
680 base_ref: None,
681 pull: None,
682 filter_ref: git_ref,
683 paths: None,
684 compare: None,
685 payload,
686 title: format!("Release {name} {}", action.unwrap_or("changed")),
687 trusted: true,
688 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'689 merge: None,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts690 })
691 }
692 // A deployment, or a new status of one: at the commit deployed,
693 // on the branch or tag it names (none for a bare commit).
694 "deployment" | "deployment_status" => {
695 let deployment = &data["deployment"];
696 let Some(sha) = deployment["sha"].as_str().filter(|sha| !sha.is_empty()).map(str::to_owned) else { return Ok(None) };
697 let named = deployment["ref"].as_str().unwrap_or_default();
698 let git_ref = if named.is_empty() || named == sha || is_commit(named) {
699 String::new()
700 } else if named.starts_with("refs/") {
701 named.to_owned()
702 } else if self.is_branch(repo, ws, named).await? {
703 format!("refs/heads/{named}")
704 } else {
705 format!("refs/tags/{named}")
706 };
707 let environment = deployment["environment"].as_str().unwrap_or_default();
708 let mut payload = json!({
709 "action": "created",
710 "deployment": payload::deployment(repo, deployment),
711 "repository": payload::repository(repo),
712 "sender": payload::user(sender),
713 });
714 let title = if event_name == "deployment_status" {
715 let status = &data["deploymentStatus"];
716 payload["deployment_status"] = payload::deployment_status(repo, status, deployment);
717 format!("Deployment to {environment}: {}", status["state"].as_str().unwrap_or("changed"))
718 } else {
719 format!("Deployment to {environment}")
720 };
721 Some(Subject {
722 source: path.clone(),
723 source_ref: Some(sha.clone()),
724 filter_ref: git_ref.clone(),
725 git_ref,
726 sha,
727 head_ref: None,
728 base_ref: None,
729 pull: None,
730 paths: None,
731 compare: None,
732 payload,
733 title,
734 trusted: true,
735 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'736 merge: None,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts737 })
738 }
Docs: the inbox, what lands in it and why, its tabs and actions739 _ => None,
740 })
741 }
742
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails743 /// A push keeps the repository's schedules going (`run_schedules`).
744 /// Written at most once a day, and only on scheduled workflows.
745 async fn note_push(&self, repo_id: &str) -> Result<()> {
746 let at = now_ms();
747 self.db
748 .prepare("UPDATE workflows SET pushed_at = ? WHERE repo_id = ? AND crons != '[]' AND (pushed_at IS NULL OR pushed_at < ?)")
749 .bind(&[rfc3339(at).into(), repo_id.into(), rfc3339(at.saturating_sub(24 * 60 * 60 * 1000)).into()])?
750 .run()
751 .await?;
752 Ok(())
753 }
754
Docs: the inbox, what lands in it and why, its tabs and actions755 pub async fn on_event(&self, event: &Event) -> Result<()> {
756 let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'757 let mut mapped = github_events(&event.kind);
758 // A new branch or tag is also `create`.
759 if event.kind == "git.push" && event.data["before"].is_null() {
760 mapped.push(("create", None));
761 }
Docs: the inbox, what lands in it and why, its tabs and actions762 let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true);
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails763 if event.kind == "git.push" {
764 self.note_push(repo_id).await?;
765 }
Docs: the inbox, what lands in it and why, its tabs and actions766 if mapped.is_empty() && !pushed_default {
767 return Ok(());
768 }
769 let Some((repo, ws)) = self.repo_by_id(repo_id).await? else { return Ok(()) };
770 if pushed_default {
771 self.sync(&repo, &ws).await?;
772 }
Merge branch 'worktree-agent-a3abfcce648e87dca'773 // What a workflow job's own token did starts no workflows, as on
774 // GitHub, so a workflow cannot set itself off; only
775 // `workflow_dispatch` and `repository_dispatch` do.
776 if let Some(run) = caused_by_job(&event.data) {
777 worker::console_log!("actions: {} {} came from run {run}'s token; no workflows start for it", event.kind, event.id);
778 return Ok(());
779 }
Docs: the inbox, what lands in it and why, its tabs and actions780 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
781 for (event_name, action) in mapped {
782 // Issues and comments start the default branch's workflows,
783 // which the synced table lists: when none listens, nothing is
784 // read from git. Agents make many of these events.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts785 // Deployments' statuses are as frequent (every g1t.page build
786 // reports several), so they look there first too.
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9787 if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete")
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts788 && self.listens(repo_id, event_name).await? == Some(false)
789 {
Docs: the inbox, what lands in it and why, its tabs and actions790 continue;
791 }
792 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
793 continue;
794 };
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.795 // `[skip ci]` in the head commit's message, as on GitHub.
796 if matches!(event_name, "push" | "pull_request") {
797 let message = match subject.payload["head_commit"]["message"].as_str() {
798 Some(message) => Some(message.to_owned()),
799 None => self.head_message(&subject.source, &ws, &subject.sha).await?,
800 };
801 if message.as_deref().is_some_and(g1t_actions::events::skips_ci) {
802 worker::console_log!("actions: {} {} at {} asks to skip its runs", event.kind, event.id, subject.sha);
803 continue;
Merge branch 'mirroring' into artifacts-mode804 }
805 }
806 // A pull request from a fork reads the fork's files.
807 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.808 self.read_for(&repo, &ws, subject.source_ref.as_deref()).await?
Merge branch 'mirroring' into artifacts-mode809 } 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.810 // And this repository's managed workflows: they are its own,
811 // whatever the fork's files say (managed.rs).
812 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.813 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.814 read
Merge branch 'mirroring' into artifacts-mode815 };
Docs: the inbox, what lands in it and why, its tabs and actions816 // A pull request's head runs each workflow once, however many
817 // events say it is there (marked ready, and pushed).
818 let key = match subject.pull {
819 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 timeouts820 // Reopened or made a draft again runs anew, at a head
821 // that may have run before.
822 let phase = match action {
823 Some("closed") => "closed".to_owned(),
824 Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id),
825 _ => "open".to_owned(),
826 };
Docs: the inbox, what lands in it and why, its tabs and actions827 format!("{event_name}:{number}:{}:{phase}", subject.sha)
828 }
Merge branch 'worktree-agent-a3abfcce648e87dca'829 _ if event_name == "create" => format!("{}:create", event.id),
Docs: the inbox, what lands in it and why, its tabs and actions830 _ => event.id.clone(),
831 };
832 self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender)
833 .await?;
834 }
835 Ok(())
836 }
837
838 #[allow(clippy::too_many_arguments)]
839 async fn start_matching(
840 &self,
841 repo: &Repo,
842 ws: &User,
843 read: Read,
844 subject: &mut Subject,
845 event_name: &str,
846 action: Option<&str>,
847 event_key: &str,
848 actor_id: Option<&str>,
849 sender: &str,
850 ) -> 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.851 let parsed: Vec<_> = read.files.into_iter().map(|file| (workflow::parse(&file.source), file)).collect();
852 // A push some workflow filters by path is compared before any run
853 // starts, so every run's payload lists what its commit changed.
854 let filters_paths = |(parsed, _): &(std::result::Result<Workflow, _>, _)| {
855 parsed.as_ref().ok().and_then(|w: &Workflow| w.trigger(event_name)).is_some_and(|t| t.paths.is_set())
856 };
857 if event_name == "push" && subject.paths.is_none() && parsed.iter().any(filters_paths) {
858 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
859 subject.changed(self.changed_paths(repo, ws, base, head).await?);
860 }
861 for (parsed, file) in parsed {
Docs: the inbox, what lands in it and why, its tabs and actions862 let workflow = match parsed {
863 Ok(workflow) => workflow,
864 Err(problem) => {
865 // A push shows a broken workflow as a failed run, as GitHub does.
866 if event_name == "push" && file.source.contains("on") {
867 self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
868 .await?;
869 }
870 continue;
871 }
872 };
873 let Some(trigger) = workflow.trigger(event_name) else { continue };
874 // workflow_run follows the workflows it names.
875 if event_name == "workflow_run" {
876 let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default();
877 if !trigger.workflows.iter().any(|name| name == followed) {
878 continue;
879 }
880 }
881 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
882 continue;
883 }
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.884 // A workspace's required workflow runs whatever its row says.
885 if !file.required && self.disabled(&repo.id, &file.path).await? {
Docs: the inbox, what lands in it and why, its tabs and actions886 continue;
887 }
888 self.create_run(NewRun {
889 repo: repo.clone(),
890 path: file.path,
891 source: file.source,
892 info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id),
893 workflow,
894 action: action.map(str::to_owned),
895 pull: subject.pull,
896 title: subject.title.clone(),
897 inputs: Map::new(),
898 event_key: event_key.to_owned(),
899 actor_id: actor_id.map(str::to_owned),
900 actor: Some(sender.to_owned()),
901 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.902 approval: subject.approval.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'903 head_sha: subject.merge.as_ref().map(|_| subject.sha.clone()),
Docs: the inbox, what lands in it and why, its tabs and actions904 })
905 .await?;
906 }
907 Ok(())
908 }
909
910 /// Whether the branch, tag and path filters let the event through.
911 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-7nm8r9912 // GitHub takes no filters on these: a workflow with `branches` on
913 // `create` or `delete` still runs for every branch and tag.
914 if unfiltered(event_name) {
915 return Ok(true);
916 }
Docs: the inbox, what lands in it and why, its tabs and actions917 let git_ref = subject.filter_ref.as_str();
918 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
919 // A tag push runs a workflow that filters tags, or filters nothing.
920 if trigger.tags.is_set() {
921 if !trigger.tags.allows(tag) {
922 return Ok(false);
923 }
924 } else if trigger.branches.is_set() {
925 return Ok(false);
926 }
927 // Paths are not checked for tags, as on GitHub.
928 return Ok(true);
929 }
930 let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref);
931 if trigger.branches.is_set() {
932 if !trigger.branches.allows(branch) {
933 return Ok(false);
934 }
935 } else if event_name == "push" && trigger.tags.is_set() {
936 return Ok(false);
937 }
938 if trigger.paths.is_set() {
939 if subject.paths.is_none() {
940 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.941 subject.changed(self.changed_paths(repo, ws, base, head).await?);
Docs: the inbox, what lands in it and why, its tabs and actions942 }
943 if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
944 return Ok(false);
945 }
946 }
947 Ok(true)
948 }
949
950 async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> {
951 let row = self
952 .db
953 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
954 .bind(&[repo_id.into(), path.into()])?
955 .first::<WorkflowRow>(None)
956 .await?;
957 Ok(row.is_some_and(|row| row.state == "disabled"))
958 }
959
960 fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo {
961 RunInfo {
962 repository: format!("{}/{}", repo.namespace, repo.name),
963 repository_id: repo.id.clone(),
964 default_branch: repo.default_branch.clone(),
965 event_name: event_name.to_owned(),
966 event: subject.payload.clone(),
967 git_ref: subject.git_ref.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'968 sha: subject.merge.clone().unwrap_or_else(|| subject.sha.clone()),
Docs: the inbox, what lands in it and why, its tabs and actions969 head_ref: subject.head_ref.clone(),
970 base_ref: subject.base_ref.clone(),
971 actor: sender.to_owned(),
972 actor_id: actor_id.unwrap_or_default().to_owned(),
973 triggering_actor: sender.to_owned(),
974 run_id: String::new(),
975 run_number: 0,
976 run_attempt: 1,
977 workflow: workflow.name.clone().unwrap_or_default(),
978 workflow_path: String::new(),
979 server_url: SITE.to_owned(),
980 api_url: API.to_owned(),
981 }
982 }
983
984 #[allow(clippy::too_many_arguments)]
985 async fn record_invalid(
986 &self,
987 repo: &Repo,
988 path: &str,
989 source: &str,
990 subject: &Subject,
991 event_key: &str,
992 actor_id: Option<&str>,
993 sender: &str,
994 problem: &str,
995 ) -> Result<()> {
996 let row = self.workflow_row(repo, path, path, source).await?;
997 self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
998 }
999
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1000 /// Scheduled workflows that run this minute, on the default branch: at
1001 /// most every five minutes (`cron::Schedule::runs_at`). Not in a
1002 /// repository with no push for [`crate::SCHEDULE_IDLE_MS`], nor for an
1003 /// hour after billing refused one of the workflow's scheduled jobs
1004 /// ([`crate::SCHEDULE_REFUSED_MS`]): no run is made only to be refused.
Docs: the inbox, what lands in it and why, its tabs and actions1005 pub async fn run_schedules(&self, minute: u64) -> Result<()> {
1006 let rows = self
1007 .db
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1008 .prepare(
1009 "SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL
1010 AND COALESCE(pushed_at, updated_at) >= ? AND (schedule_refused_until IS NULL OR schedule_refused_until <= ?)",
1011 )
1012 .bind(&[rfc3339(minute.saturating_sub(crate::SCHEDULE_IDLE_MS)).into(), rfc3339(minute).into()])?
Docs: the inbox, what lands in it and why, its tabs and actions1013 .all()
1014 .await?
1015 .results::<WorkflowRow>()?;
1016 for row in rows {
1017 let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1018 let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.runs_at(minute))) else {
Docs: the inbox, what lands in it and why, its tabs and actions1019 continue;
1020 };
1021 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.1022 // Schedules wait while a repository is archived; a deleted one is
1023 // not found.
Merge branch 'mirroring' into artifacts-mode1024 let Some((repo, _ws)) = self
1025 .repo_by_id(&row.repo_id)
1026 .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.1027 .filter(|(repo, _)| !repo.archived())
Merge branch 'mirroring' into artifacts-mode1028 else {
1029 continue;
1030 };
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.1031 // A managed workflow's row may be out of date while its
1032 // repository waits to be synced again (managed.rs).
1033 if g1t_actions::managed::is_managed(&row.path) && !self.synced(&repo.id).await? {
1034 continue;
1035 }
Docs: the inbox, what lands in it and why, its tabs and actions1036 let Some(sha) = self.default_head(&repo).await? else { continue };
1037 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
1038 let mut subject = Subject {
1039 source: Self::repo_path(&repo),
1040 source_ref: None,
1041 git_ref: format!("refs/heads/{}", repo.default_branch),
1042 sha,
1043 head_ref: None,
1044 base_ref: None,
1045 pull: None,
1046 filter_ref: String::new(),
1047 paths: None,
1048 compare: None,
1049 payload,
1050 title: format!("Scheduled: {cron}"),
1051 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1052 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1053 merge: None,
Docs: the inbox, what lands in it and why, its tabs and actions1054 };
1055 subject.filter_ref = subject.git_ref.clone();
1056 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
1057 self.create_run(NewRun {
1058 repo: repo.clone(),
1059 path: row.path.clone(),
1060 source: row.source.clone(),
1061 workflow,
1062 info,
1063 action: None,
1064 pull: None,
1065 title: subject.title.clone(),
1066 inputs: Map::new(),
1067 event_key: format!("schedule:{minute}"),
1068 actor_id: None,
1069 actor: None,
1070 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1071 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1072 head_sha: None,
Docs: the inbox, what lands in it and why, its tabs and actions1073 })
1074 .await?;
1075 }
1076 Ok(())
1077 }
1078
1079 /// `dispatch`: someone with the Write role runs a workflow that has
1080 /// `workflow_dispatch`.
1081 pub async fn dispatch(&self, a: DispatchArgs) -> Result<Outcome<WorkflowRun>> {
1082 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
1083 if repo.archived() {
1084 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1085 }
1086 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1087 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1088 };
1089 let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
1090 let full_ref = if git_ref.starts_with("refs/") {
1091 git_ref.clone()
1092 } else {
1093 // A branch if there is one by that name, otherwise a tag.
1094 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
1095 &self.repos,
1096 "branches",
1097 &g1t_contracts::repos::BranchesArgs {
1098 path: Self::repo_path(&repo),
1099 viewer: Some(ws.clone()),
1100 },
1101 )
1102 .await?;
1103 let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
1104 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
1105 };
1106 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.1107 let read = self.read_for(&repo, &ws, Some(&short)).await?;
Docs: the inbox, what lands in it and why, its tabs and actions1108 let Some(sha) = read.head.clone() else {
1109 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
1110 };
1111 // A workflow is named by its file (`build.yml`), its path, or its id
1112 // (`wfl_…`), which stands for the path it was read from.
1113 let by_id = if a.workflow.starts_with("wfl_") {
1114 self.db
1115 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?")
1116 .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])?
1117 .first::<WorkflowRow>(None)
1118 .await?
1119 .map(|row| row.path)
1120 } else {
1121 None
1122 };
1123 let named = by_id.as_deref().unwrap_or(&a.workflow);
1124 let wanted = named.trim_start_matches(".g1t/workflows/");
1125 let Some(file) = read.files.iter().find(|file| {
1126 file.path.rsplit('/').next() == Some(wanted) || file.path == named
1127 }) else {
1128 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
1129 };
1130 let workflow = match workflow::parse(&file.source) {
1131 Ok(workflow) => workflow,
1132 Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
1133 };
1134 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
1135 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
1136 };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91137 // `type: environment` names one of the repository's environments.
1138 let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) {
1139 self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect()
1140 } else {
1141 Vec::new()
1142 };
1143 let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments));
Docs: the inbox, what lands in it and why, its tabs and actions1144 let payload = json!({
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91145 // As GitHub gives them here: text, `true` and `false` included.
1146 // The `inputs` context keeps booleans and numbers.
1147 "inputs": event_inputs(&inputs),
Docs: the inbox, what lands in it and why, its tabs and actions1148 "ref": full_ref,
1149 "repository": payload::repository(&repo),
1150 "sender": payload::user(&a.actor.username),
1151 "workflow": file.path,
1152 });
1153 let subject = Subject {
1154 source: Self::repo_path(&repo),
1155 source_ref: Some(sha.clone()),
1156 git_ref: full_ref.clone(),
1157 sha,
1158 head_ref: None,
1159 base_ref: None,
1160 pull: None,
1161 filter_ref: full_ref,
1162 paths: None,
1163 compare: None,
1164 payload,
1165 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
1166 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1167 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1168 merge: None,
Docs: the inbox, what lands in it and why, its tabs and actions1169 };
1170 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1171 let created = self
1172 .create_run(NewRun {
1173 repo: repo.clone(),
1174 path: file.path.clone(),
1175 source: file.source.clone(),
1176 workflow,
1177 info,
1178 action: None,
1179 pull: None,
1180 title: subject.title.clone(),
1181 inputs,
1182 event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
1183 actor_id: Some(a.actor.id.clone()),
1184 actor: Some(a.actor.username.clone()),
1185 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1186 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1187 head_sha: None,
Docs: the inbox, what lands in it and why, its tabs and actions1188 })
1189 .await?;
1190 match created {
1191 Some(id) => self.run_summary(&id).await,
1192 None => Ok(fail(FailureCode::Conflict, "It did not start.")),
1193 }
1194 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1195
1196 /// `repository_dispatch`: an outside event, by name, starts the default
1197 /// branch's workflows that run `on: repository_dispatch` with that type
1198 /// (or with no `types`). A workflow job's token may send one: this,
1199 /// with `workflow_dispatch`, is how a workflow starts another.
1200 pub async fn repository_dispatch(&self, a: RepositoryDispatchArgs) -> Result<Outcome<u32>> {
1201 let repo = check!(self.may(&a.actor, &a.repo, Capability::Push).await?);
1202 if repo.archived() {
1203 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1204 }
1205 let event_type = a.event_type.trim().to_owned();
1206 if event_type.is_empty() || event_type.chars().count() > 100 {
1207 return Ok(fail(FailureCode::Invalid, "event_type is 1 to 100 characters."));
1208 }
1209 let client_payload = match a.client_payload {
1210 Value::Null => json!({}),
1211 Value::Object(map) if map.len() <= 10 => Value::Object(map),
1212 Value::Object(_) => return Ok(fail(FailureCode::Invalid, "client_payload has at most 10 top-level properties.")),
1213 _ => return Ok(fail(FailureCode::Invalid, "client_payload is a JSON object.")),
1214 };
1215 if serde_json::to_string(&client_payload).map_or(0, |text| text.len()) > 64 * 1024 {
1216 return Ok(fail(FailureCode::Invalid, "client_payload is at most 64 KB."));
1217 }
1218 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1219 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1220 };
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.1221 let read = self.read_for(&repo, &ws, Some(&repo.default_branch)).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1222 let Some(sha) = read.head.clone() else {
1223 return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet."));
1224 };
1225 let git_ref = format!("refs/heads/{}", repo.default_branch);
1226 let payload = json!({
1227 "action": event_type,
1228 "branch": repo.default_branch,
1229 "client_payload": client_payload,
1230 "repository": payload::repository(&repo),
1231 "sender": payload::user(&a.actor.username),
1232 });
1233 let key = format!("repository_dispatch:{}", new_id("dsp", now_ms()));
1234 let mut started = 0u32;
1235 for file in read.files {
1236 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1237 let Some(trigger) = workflow.trigger("repository_dispatch") else { continue };
1238 if !trigger.wants_type(Some(&event_type)) || self.disabled(&repo.id, &file.path).await? {
1239 continue;
1240 }
1241 let subject = Subject {
1242 source: Self::repo_path(&repo),
1243 source_ref: Some(sha.clone()),
1244 git_ref: git_ref.clone(),
1245 sha: sha.clone(),
1246 head_ref: None,
1247 base_ref: None,
1248 pull: None,
1249 filter_ref: git_ref.clone(),
1250 paths: None,
1251 compare: None,
1252 payload: payload.clone(),
1253 title: event_type.clone(),
1254 trusted: true,
1255 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1256 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1257 };
1258 let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1259 let created = self
1260 .create_run(NewRun {
1261 repo: repo.clone(),
1262 path: file.path.clone(),
1263 source: file.source.clone(),
1264 workflow,
1265 info,
1266 action: Some(event_type.clone()),
1267 pull: None,
1268 title: subject.title.clone(),
1269 inputs: Map::new(),
1270 event_key: key.clone(),
1271 actor_id: Some(a.actor.id.clone()),
1272 actor: Some(a.actor.username.clone()),
1273 trusted: true,
1274 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1275 head_sha: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1276 })
1277 .await?;
1278 if created.is_some() {
1279 started += 1;
1280 }
1281 }
1282 Ok(Outcome::Ok(started))
1283 }
Docs: the inbox, what lands in it and why, its tabs and actions1284}
1285
1286#[derive(serde::Deserialize)]
1287#[serde(rename_all = "camelCase")]
1288pub struct MergeGroupArgs {
1289 pub repo_id: String,
1290 pub entry: String,
1291 pub sha: String,
1292 pub head_ref: String,
1293 #[serde(default)]
1294 pub base_sha: Option<String>,
1295 pub number: u32,
1296 #[serde(default)]
1297 pub ahead: Vec<u32>,
1298}
1299
1300impl Actions {
1301 /// `merge_group`: the merge queue built a state and its checks passed.
1302 /// Starts the workflows that run `on: merge_group` on it, as GitHub's
1303 /// queue does, and says how many started; the queue waits for their
1304 /// statuses on that commit.
1305 pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
1306 let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
1307 return Ok(Outcome::Ok(json!({ "runs": 0 })));
1308 };
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.1309 let mut read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
1310 // A required workflow's check holds the queue too (managed.rs).
1311 read.files = self.with_managed(&repo, read.files).await?;
Docs: the inbox, what lands in it and why, its tabs and actions1312 let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
1313 let payload = json!({
1314 "action": "checks_requested",
1315 "merge_group": {
1316 "head_sha": a.sha,
1317 "head_ref": a.head_ref,
1318 "base_sha": a.base_sha,
1319 "base_ref": format!("refs/heads/{}", repo.default_branch),
1320 "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
1321 },
1322 "repository": payload::repository(&repo),
1323 "sender": payload::user(&repo.namespace),
1324 });
1325 let mut started = 0u32;
1326 for file in read.files {
1327 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1328 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.1329 if !trigger.wants_type(Some("checks_requested")) || (!file.required && self.disabled(&repo.id, &file.path).await?) {
Docs: the inbox, what lands in it and why, its tabs and actions1330 continue;
1331 }
1332 // Branch filters on merge_group name the branch it merges into.
1333 if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
1334 continue;
1335 }
1336 let ahead = if a.ahead.is_empty() {
1337 String::new()
1338 } else {
1339 format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
1340 };
1341 let subject = Subject {
1342 source: Self::repo_path(&repo),
1343 source_ref: Some(a.sha.clone()),
1344 git_ref: a.head_ref.clone(),
1345 sha: a.sha.clone(),
1346 head_ref: None,
1347 base_ref: Some(repo.default_branch.clone()),
1348 pull: Some(a.number),
1349 filter_ref: format!("refs/heads/{}", repo.default_branch),
1350 paths: None,
1351 compare: None,
1352 payload: payload.clone(),
1353 title: format!("Merge queue: #{}{ahead}", a.number),
1354 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1355 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1356 merge: None,
Docs: the inbox, what lands in it and why, its tabs and actions1357 };
1358 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
1359 let created = self
1360 .create_run(NewRun {
1361 repo: repo.clone(),
1362 path: file.path.clone(),
1363 source: file.source.clone(),
1364 workflow,
1365 info,
1366 action: Some("checks_requested".to_owned()),
1367 pull: Some(a.number),
1368 title: subject.title.clone(),
1369 inputs: Map::new(),
1370 event_key: format!("merge_group:{}:{}", a.entry, a.sha),
1371 actor_id: None,
1372 actor: None,
1373 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1374 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1375 head_sha: None,
Docs: the inbox, what lands in it and why, its tabs and actions1376 })
1377 .await?;
1378 if created.is_some() {
1379 started += 1;
1380 }
1381 }
1382 Ok(Outcome::Ok(json!({ "runs": started })))
1383 }
1384}
1385
1386/// The inputs of a manual run: what was given, checked against the
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91387/// workflow's declared inputs, with their defaults filled in. An input the
1388/// workflow does not declare is refused, as GitHub refuses it.
1389/// `environments` are the repository's, for `type: environment`.
1390fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> {
1391 let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect();
1392 if !unexpected.is_empty() {
1393 let names = serde_json::to_string(&unexpected).unwrap_or_default();
1394 let declared = if trigger.inputs.is_empty() {
1395 "The workflow declares no inputs.".to_owned()
1396 } else {
1397 format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", "))
1398 };
1399 return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}"));
1400 }
Docs: the inbox, what lands in it and why, its tabs and actions1401 let mut inputs = Map::new();
1402 for (name, spec) in &trigger.inputs {
1403 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
1404 let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
1405 let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
1406 let value = match value {
1407 Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
1408 Some(Value::Null) | None => match kind {
1409 "boolean" => Value::Bool(false),
1410 _ => Value::String(String::new()),
1411 },
1412 Some(value) => match kind {
1413 "boolean" => Value::Bool(match &value {
1414 Value::Bool(flag) => *flag,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91415 Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true,
1416 Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false,
1417 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")),
Docs: the inbox, what lands in it and why, its tabs and actions1418 }),
1419 "number" => match &value {
1420 Value::Number(_) => value,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91421 Value::String(text) => match number(text) {
Docs: the inbox, what lands in it and why, its tabs and actions1422 Some(number) => Value::Number(number),
1423 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1424 },
1425 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1426 },
1427 "choice" => {
1428 let text = g1t_actions::expr::to_text(&value);
1429 let options: Vec<String> =
1430 spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
1431 if !options.is_empty() && !options.contains(&text) {
1432 return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
1433 }
1434 Value::String(text)
1435 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91436 "environment" => {
1437 let text = g1t_actions::expr::to_text(&value);
1438 let wanted = text.trim().to_ascii_lowercase();
1439 if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) {
1440 return fail(
1441 FailureCode::Invalid,
1442 format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()),
1443 );
1444 }
1445 Value::String(text)
1446 }
Docs: the inbox, what lands in it and why, its tabs and actions1447 _ => Value::String(g1t_actions::expr::to_text(&value)),
1448 },
1449 };
1450 inputs.insert(name.clone(), value);
1451 }
1452 Outcome::Ok(inputs)
1453}
1454
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91455/// A number input given as text: a whole number stays one.
1456fn number(text: &str) -> Option<serde_json::Number> {
1457 let text = text.trim();
1458 match text.parse::<i64>() {
1459 Ok(whole) => Some(whole.into()),
1460 Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64),
1461 }
1462}
1463
1464/// A manual run's inputs as `github.event.inputs` gives them: every one as
1465/// text, as GitHub's `workflow_dispatch` payload has them.
1466fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> {
1467 inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect()
1468}
1469
Docs: the inbox, what lands in it and why, its tabs and actions1470#[cfg(test)]
1471mod tests {
1472 use super::*;
1473 use g1t_contracts::work::{Pull, g1t_author};
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1474
1475 #[test]
1476 fn every_event_a_workflow_runs_on_is_sent_to_this_queue() {
1477 for kind in g1t_contracts::webhooks::EVENT_TYPES {
1478 if !github_events(kind).is_empty() {
1479 assert!(
1480 g1t_contracts::subscribers::routed("SUBSCRIBER_ACTIONS", kind),
1481 "{kind} starts workflows but is not routed to actions (g1t_contracts::subscribers)"
1482 );
1483 }
1484 }
1485 }
Docs: the inbox, what lands in it and why, its tabs and actions1486 use g1t_contracts::{Membership, PrincipalKind};
1487
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1488 #[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.1489 fn a_pushers_email_is_their_noreply_address() {
1490 assert_eq!(pusher_email(Some("usr_01j9zq4m8x7k2v5n3b6c1d0efg"), "Ada").as_deref(), Some("6c1d0efg+ada@users.noreply.g1t.sh"));
1491 assert_eq!(pusher_email(Some(AGENT_ID), AGENT_NAME).as_deref(), Some(g1t_contracts::system::EMAIL));
1492 assert_eq!(pusher_email(Some("wsp_1"), "acme"), None);
1493 assert_eq!(pusher_email(None, "acme"), None);
1494 }
1495
1496 #[test]
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1497 fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() {
1498 let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
1499 let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
1500 let unavailable = PullMergeRef::Unavailable { reason: "too large".into() };
1501 assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into()));
1502 assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into()));
1503 // GitHub runs no pull_request workflow while it conflicts.
1504 for action in ["opened", "synchronize", "reopened", "edited"] {
1505 assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing);
1506 }
1507 // Closing it, or reviewing it, still runs, on the head.
1508 assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head);
1509 assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head);
1510 // What g1t could not merge itself runs on the head.
1511 assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head);
1512 }
1513
Docs: the inbox, what lands in it and why, its tabs and actions1514 fn repo() -> Repo {
1515 serde_json::from_value(json!({
1516 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
1517 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
1518 }))
1519 .unwrap()
1520 }
1521
1522 fn person(id: &str, username: &str) -> User {
1523 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
1524 }
1525
1526 fn made_for(asker: User) -> Pull {
1527 serde_json::from_value(json!({
1528 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
1529 "agent": "g1t", "runtime": "hosted", "status": "open",
1530 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
1531 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
1532 "supersededBy": null, "checkStatus": null,
1533 "author": g1t_author(), "requestedBy": asker,
1534 "createdAt": "", "updatedAt": ""
1535 }))
1536 .unwrap()
1537 }
1538
1539 #[test]
1540 fn g1t_s_change_for_someone_is_trusted_as_they_are() {
1541 // Stored people carry no memberships, so identity is asked about
1542 // them; being g1t's change gives it nothing more.
1543 let pull = made_for(person("usr_2", "ana"));
1544 assert!(!trusted_outright(pull.owner(), &repo()));
1545 // Someone known to be able to push is trusted at once.
1546 let mut member = person("usr_1", "syntaqx");
1547 member.workspaces.push(Membership::member("acme"));
1548 let pull = made_for(member);
1549 assert!(trusted_outright(pull.owner(), &repo()));
1550 }
1551
1552 #[test]
1553 fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() {
1554 let pull = made_for(person("usr_1", "syntaqx"));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1555 let event = payload::pull(&repo(), &pull);
Docs: the inbox, what lands in it and why, its tabs and actions1556 assert_eq!(event["user"]["login"], "g1t");
1557 assert_eq!(event["user"]["type"], "Bot");
1558 assert_eq!(event["requested_by"]["login"], "syntaqx");
1559 assert_eq!(event["requested_by"]["type"], "User");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1560 let as_issue = payload::pull_as_issue(&repo(), &pull);
Docs: the inbox, what lands in it and why, its tabs and actions1561 assert_eq!(as_issue["user"]["login"], "g1t");
1562 assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
1563 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1564
1565 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1566 fn releases_deployments_and_deleted_comments_read_as_githubs() {
1567 let release = payload::release(
1568 &repo(),
1569 &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false,
1570 "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }),
1571 );
1572 assert_eq!(release["tag_name"], "v1.2.0");
1573 assert_eq!(release["name"], "v1.2.0");
1574 assert_eq!(release["prerelease"], true);
1575 assert_eq!(release["author"]["login"], "ana");
1576 assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0");
1577 let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana",
1578 "production_environment": false, "created_at": "t", "updated_at": "t" });
1579 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);
1580 assert_eq!(status["state"], "success");
1581 assert_eq!(status["environment"], "staging");
1582 assert_eq!(status["environment_url"], "https://s.example");
1583 assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1584 let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1585 assert_eq!(gone["user"]["login"], "bo");
1586 assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1587 assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
1588 assert!(!is_commit("main"));
1589 }
1590
1591 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1592 fn a_pull_request_names_its_own_base_labels_and_milestone() {
1593 let mut pull = made_for(person("usr_1", "syntaqx"));
1594 let event = payload::pull(&repo(), &pull);
1595 assert_eq!(event["base"]["ref"], repo().default_branch);
1596 pull.base = Some("release/1.x".into());
1597 pull.labels = vec!["bug".into()];
1598 pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() });
1599 let event = payload::pull(&repo(), &pull);
1600 assert_eq!(event["base"]["ref"], "release/1.x");
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1601 assert_eq!(event["labels"][0]["name"], "bug");
1602 assert!(event["labels"][0]["id"].is_u64());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1603 assert_eq!(event["milestone"]["title"], "1.1");
1604 let mut labeled = serde_json::json!({ "action": "labeled" });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1605 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 bar1606 assert_eq!(labeled["label"]["color"], "d73a4a");
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91607 let mut assigned = serde_json::json!({ "action": "assigned" });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1608 payload::changed(&repo(), &mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91609 assert_eq!(assigned["assignee"]["login"], "bo");
1610 assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] })));
1611 assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
1612 }
1613
1614 fn dispatchable(yaml: &str) -> Trigger {
1615 workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone()
1616 }
1617
1618 fn given(value: Value) -> Map<String, Value> {
1619 value.as_object().unwrap().clone()
1620 }
1621
1622 const DISPATCH: &str = "on:
1623 workflow_dispatch:
1624 inputs:
1625 dry-run: { type: boolean, default: false }
1626 count: { type: number, default: 2 }
1627 target: { type: environment }
1628 level: { type: choice, options: [low, high], default: low }
1629 note: { required: false }
1630jobs:
1631 a:
1632 runs-on: x
1633 steps: [{ run: 'true' }]";
1634
1635 #[test]
1636 fn an_input_the_workflow_does_not_declare_is_refused() {
1637 let trigger = dispatchable(DISPATCH);
1638 let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else {
1639 panic!("an undeclared input was taken");
1640 };
1641 assert_eq!(failure.code, FailureCode::Invalid);
1642 assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message);
1643 assert!(failure.message.contains("`dry-run`"));
1644 let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]");
1645 let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() };
1646 assert!(failure.message.contains("declares no inputs"));
1647 assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty()));
1648 }
1649
1650 #[test]
1651 fn inputs_are_typed_and_the_event_has_them_as_text() {
1652 let trigger = dispatchable(DISPATCH);
1653 let environments = vec!["staging".to_owned(), "production".to_owned()];
1654 let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments)
1655 else {
1656 panic!("refused");
1657 };
1658 assert_eq!(inputs["dry-run"], json!(true));
1659 assert_eq!(inputs["count"], json!(3));
1660 assert_eq!(inputs["level"], json!("low"));
1661 assert_eq!(inputs["note"], json!(""));
1662 let event = event_inputs(&inputs);
1663 assert_eq!(event["dry-run"], json!("true"));
1664 assert_eq!(event["count"], json!("3"));
1665 assert_eq!(event["target"], json!("Staging"));
1666 let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() };
1667 assert_eq!(event_inputs(&defaults)["dry-run"], json!("false"));
1668 assert_eq!(defaults["count"], json!(2));
1669 let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) {
1670 Outcome::Fail(failure) => failure.message,
1671 Outcome::Ok(_) => panic!("taken"),
1672 };
1673 assert!(refused(json!({ "target": "qa" })).contains("none called `qa`"));
1674 assert!(refused(json!({ "dry-run": "yes" })).contains("true or false"));
1675 assert!(refused(json!({ "count": "many" })).contains("is a number"));
1676 assert!(refused(json!({ "level": "mid" })).contains("low, high"));
1677 }
1678
1679 #[test]
1680 fn create_and_delete_take_no_filters_and_name_their_ref() {
1681 assert!(unfiltered("create") && unfiltered("delete"));
1682 assert!(!unfiltered("push") && !unfiltered("pull_request"));
1683 assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x")));
1684 assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0")));
1685 assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None);
1686 assert_eq!(branch_or_tag("refs/notes/commits"), None);
1687 // A workflow can name `delete`. It takes no filters, as GitHub's
1688 // schema says, so writing one is refused when the workflow is read.
1689 assert!(workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").is_err());
1690 let workflow = workflow::parse("on:\n delete:\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1691 let trigger = workflow.trigger("delete").unwrap();
1692 assert!(trigger.wants_type(None));
1693 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 bar1694 }
Docs: the inbox, what lands in it and why, its tabs and actions1695}

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