Skip to content
1,729 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.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! 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};
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor10use 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};
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor13use g1t_contracts::{FailureCode, Outcome, User, new_id};
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails14use g1t_contracts::time::rfc3339;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor15use 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
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor27/// 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>,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor54}
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
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor130/// Whether whoever a pull request is for is trusted without asking
131/// identity: g1t's agent in work nobody asked it for, or someone whose
132/// role here is known to allow pushing.
133fn trusted_outright(owner: &User, repo: &Repo) -> bool {
134 owner.id == AGENT_ID || access::can(Some(owner), repo, Capability::Push)
135}
136
137impl Actions {
138 async fn username(&self, id: Option<&str>) -> Result<Option<String>> {
139 let Some(id) = id else { return Ok(None) };
140 if id == AGENT_ID {
141 return Ok(Some(AGENT_NAME.to_owned()));
142 }
143 let names: std::collections::HashMap<String, String> =
144 g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.to_owned()] }).await?;
145 Ok(names.get(id).cloned())
146 }
147
148 /// Whether whoever a pull request is for (Pull::owner: whoever asked
149 /// g1t for it, or its author) could push to the repository, so its
150 /// runs get the secrets and a token. Anyone else's, a reader's included
151 /// (who may open one on a private repository too), runs without them.
152 /// A change g1t made for someone is trusted as they are.
153 async fn insider(&self, owner: &User, repo: &Repo, ws: &User) -> Result<bool> {
154 if trusted_outright(owner, repo) {
155 return Ok(true);
156 }
157 // Stored authors carry no memberships or grants: ask identity, as
158 // the workspace (which may see anyone's permission).
159 let permission: Outcome<access::PermissionInfo> = g1t_kit::call(
160 &self.identity,
161 "collaborator_permission",
162 &access::CollaboratorPermissionArgs {
163 viewer: Some(ws.clone()),
164 path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
165 username: owner.username.clone(),
166 },
167 )
168 .await?;
169 Ok(permission
170 .into_result()
171 .ok()
172 .and_then(|info| info.role)
173 .is_some_and(|role| access::allows(role, Capability::Push)))
174 }
175
176 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'177 Ok(self.pushed_commits(repo, actor, after, before).await?.0)
178 }
179
180 /// The commits a push added, oldest first, and whether it was forced:
181 /// `before` is not in `after`'s history. Known only when that history
182 /// is read whole (it is shorter than the [`PUSH_LOG`] commits read);
183 /// a longer one says not forced.
184 async fn pushed_commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<(Vec<Commit>, bool)> {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor185 let log: Outcome<Vec<Commit>> = g1t_kit::call(
186 &self.repos,
187 "log",
188 &LogArgs {
189 path: RepoPath {
190 namespace: repo.namespace.clone(),
191 name: repo.name.clone(),
192 },
193 viewer: Some(actor.clone()),
194 git_ref: Some(after.to_owned()),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'195 limit: PUSH_LOG,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor196 },
197 )
198 .await?;
199 let mut commits: Vec<Commit> = log.into_result().unwrap_or_default();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'200 let whole = commits.len() < PUSH_LOG as usize;
201 let mut forced = false;
202 if let Some(before) = before.filter(|before| *before != payload::ZERO) {
203 match commits.iter().position(|commit| commit.hash == before) {
204 Some(at) => commits.truncate(at),
205 None => forced = whole && !commits.is_empty(),
206 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor207 }
208 // GitHub lists them oldest first, with the head commit last.
209 commits.reverse();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'210 Ok((commits, forced))
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor211 }
212
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.213 /// The message of the commit `sha` in `path`, when it can be read.
214 async fn head_message(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<String>> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'215 Ok(self.commit_at(path, actor, sha).await?.map(|commit| commit.message))
216 }
217
218 /// The commit `sha` in `path`, when it can be read.
219 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.220 let log: Outcome<Vec<Commit>> = g1t_kit::call(
221 &self.repos,
222 "log",
223 &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 },
224 )
225 .await?;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'226 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.227 }
228
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.229 /// What `head` changes from `base`: each path and how.
230 async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<(String, FileStatus)>> {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor231 let compared: Outcome<Comparison> = g1t_kit::call(
232 &self.repos,
233 "compare",
234 &CompareArgs {
235 repo_id: repo.id.clone(),
236 viewer: Some(actor.clone()),
237 base,
238 head: Some(head),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar239 base_branch: None,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor240 },
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(())
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor257 }
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>> {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor265 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(),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor271 },
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
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor309 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,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor336 };
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?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor354 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);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor357 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,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor373 })
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 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor427 "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 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor434 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();
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor438 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),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor442 "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 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor458 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);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor461 }
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 };
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor472 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}");
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor480 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 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor514 };
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 };
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor523 Some(Subject {
524 source,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'525 source_ref: Some(source_ref),
526 merge,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor527 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()),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor531 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}"),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor534 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,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor540 })
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 };
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor586 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,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor592 "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),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor596 },
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 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor610 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)
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor618 }
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?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor628 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();
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor648 }
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 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor739 _ => 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
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor755 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 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor762 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 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor766 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 'mirroring' into artifacts-mode773 // A mirror runs only what its state says (mirrored.rs).
774 let copied_in = event.kind == "git.push" && event.data["mirrored"].as_bool() == Some(true);
775 let policy = crate::mirrored::policy(repo.mirror.as_ref(), copied_in);
776 if !policy.runs {
777 return Ok(());
778 }
Merge branch 'worktree-agent-a3abfcce648e87dca'779 // What a workflow job's own token did starts no workflows, as on
780 // GitHub, so a workflow cannot set itself off; only
781 // `workflow_dispatch` and `repository_dispatch` do.
782 if let Some(run) = caused_by_job(&event.data) {
783 worker::console_log!("actions: {} {} came from run {run}'s token; no workflows start for it", event.kind, event.id);
784 return Ok(());
785 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor786 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
787 for (event_name, action) in mapped {
Fewer Artifacts reads: the store is asked for a handle only when needed, objects are kept in the isolate, and issue events read no workflows nobody listens for788 // Issues and comments start the default branch's workflows,
789 // which the synced table lists: when none listens, nothing is
790 // read from git. Agents make many of these events.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts791 // Deployments' statuses are as frequent (every g1t.page build
792 // reports several), so they look there first too.
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9793 if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete")
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts794 && self.listens(repo_id, event_name).await? == Some(false)
795 {
Fewer Artifacts reads: the store is asked for a handle only when needed, objects are kept in the isolate, and issue events read no workflows nobody listens for796 continue;
797 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor798 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
799 continue;
800 };
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.801 // `[skip ci]` in the head commit's message, as on GitHub.
802 if matches!(event_name, "push" | "pull_request") {
803 let message = match subject.payload["head_commit"]["message"].as_str() {
804 Some(message) => Some(message.to_owned()),
805 None => self.head_message(&subject.source, &ws, &subject.sha).await?,
806 };
807 if message.as_deref().is_some_and(g1t_actions::events::skips_ci) {
808 worker::console_log!("actions: {} {} at {} asks to skip its runs", event.kind, event.id, subject.sha);
809 continue;
810 }
811 }
Merge branch 'mirroring' into artifacts-mode812 // A change to workflows made on the remote, by someone g1t
813 // knows nothing of, waits before it may use this repository's
814 // secrets.
815 if copied_in && matches!(event_name, "push" | "create") {
816 if subject.paths.is_none() {
817 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.818 subject.changed(self.changed_paths(&repo, &ws, base, head).await?);
Merge branch 'mirroring' into artifacts-mode819 }
820 if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default())
821 && let Some(mirror) = &repo.mirror
822 {
823 subject.approval = subject.approval.take().or_else(|| Some(crate::mirrored::copied_workflows(&mirror.remote)));
824 }
825 }
826 // A pull request from a fork reads the fork's files.
827 let read = if subject.source == Self::repo_path(&repo) {
828 self.read_for(&repo, &ws, subject.source_ref.as_deref(), policy.github).await?
829 } 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.830 // And this repository's managed workflows: they are its own,
831 // whatever the fork's files say (managed.rs).
832 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.833 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.834 read
Merge branch 'mirroring' into artifacts-mode835 };
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor836 // A pull request's head runs each workflow once, however many
837 // events say it is there (marked ready, and pushed).
838 let key = match subject.pull {
839 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 timeouts840 // Reopened or made a draft again runs anew, at a head
841 // that may have run before.
842 let phase = match action {
843 Some("closed") => "closed".to_owned(),
844 Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id),
845 _ => "open".to_owned(),
846 };
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor847 format!("{event_name}:{number}:{}:{phase}", subject.sha)
848 }
Merge branch 'worktree-agent-a3abfcce648e87dca'849 _ if event_name == "create" => format!("{}:create", event.id),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor850 _ => event.id.clone(),
851 };
852 self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender)
853 .await?;
854 }
855 Ok(())
856 }
857
858 #[allow(clippy::too_many_arguments)]
859 async fn start_matching(
860 &self,
861 repo: &Repo,
862 ws: &User,
863 read: Read,
864 subject: &mut Subject,
865 event_name: &str,
866 action: Option<&str>,
867 event_key: &str,
868 actor_id: Option<&str>,
869 sender: &str,
870 ) -> 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.871 let parsed: Vec<_> = read.files.into_iter().map(|file| (workflow::parse(&file.source), file)).collect();
872 // A push some workflow filters by path is compared before any run
873 // starts, so every run's payload lists what its commit changed.
874 let filters_paths = |(parsed, _): &(std::result::Result<Workflow, _>, _)| {
875 parsed.as_ref().ok().and_then(|w: &Workflow| w.trigger(event_name)).is_some_and(|t| t.paths.is_set())
876 };
877 if event_name == "push" && subject.paths.is_none() && parsed.iter().any(filters_paths) {
878 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
879 subject.changed(self.changed_paths(repo, ws, base, head).await?);
880 }
881 for (parsed, file) in parsed {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor882 let workflow = match parsed {
883 Ok(workflow) => workflow,
884 Err(problem) => {
885 // A push shows a broken workflow as a failed run, as GitHub does.
886 if event_name == "push" && file.source.contains("on") {
887 self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
888 .await?;
889 }
890 continue;
891 }
892 };
893 let Some(trigger) = workflow.trigger(event_name) else { continue };
894 // workflow_run follows the workflows it names.
895 if event_name == "workflow_run" {
896 let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default();
897 if !trigger.workflows.iter().any(|name| name == followed) {
898 continue;
899 }
900 }
901 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
902 continue;
903 }
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.904 // A workspace's required workflow runs whatever its row says.
905 if !file.required && self.disabled(&repo.id, &file.path).await? {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor906 continue;
907 }
Merge branch 'mirroring' into artifacts-mode908 // On a mirror the remote may deploy too: a workflow that deploys
909 // waits for approval (mirrored.rs).
910 let held = crate::mirrored::policy(repo.mirror.as_ref(), false)
911 .hold
912 .zip(crate::mirrored::deploys_to(&workflow))
913 .map(|(remote, environment)| crate::mirrored::held(&remote, &environment));
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor914 self.create_run(NewRun {
915 repo: repo.clone(),
916 path: file.path,
917 source: file.source,
918 info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id),
919 workflow,
920 action: action.map(str::to_owned),
921 pull: subject.pull,
922 title: subject.title.clone(),
923 inputs: Map::new(),
924 event_key: event_key.to_owned(),
925 actor_id: actor_id.map(str::to_owned),
926 actor: Some(sender.to_owned()),
927 trusted: subject.trusted,
Merge branch 'mirroring' into artifacts-mode928 approval: subject.approval.clone().or(held),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'929 head_sha: subject.merge.as_ref().map(|_| subject.sha.clone()),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor930 })
931 .await?;
932 }
933 Ok(())
934 }
935
936 /// Whether the branch, tag and path filters let the event through.
937 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-7nm8r9938 // GitHub takes no filters on these: a workflow with `branches` on
939 // `create` or `delete` still runs for every branch and tag.
940 if unfiltered(event_name) {
941 return Ok(true);
942 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor943 let git_ref = subject.filter_ref.as_str();
944 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
945 // A tag push runs a workflow that filters tags, or filters nothing.
946 if trigger.tags.is_set() {
947 if !trigger.tags.allows(tag) {
948 return Ok(false);
949 }
950 } else if trigger.branches.is_set() {
951 return Ok(false);
952 }
953 // Paths are not checked for tags, as on GitHub.
954 return Ok(true);
955 }
956 let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref);
957 if trigger.branches.is_set() {
958 if !trigger.branches.allows(branch) {
959 return Ok(false);
960 }
961 } else if event_name == "push" && trigger.tags.is_set() {
962 return Ok(false);
963 }
964 if trigger.paths.is_set() {
965 if subject.paths.is_none() {
966 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.967 subject.changed(self.changed_paths(repo, ws, base, head).await?);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor968 }
969 if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
970 return Ok(false);
971 }
972 }
973 Ok(true)
974 }
975
976 async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> {
977 let row = self
978 .db
979 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
980 .bind(&[repo_id.into(), path.into()])?
981 .first::<WorkflowRow>(None)
982 .await?;
983 Ok(row.is_some_and(|row| row.state == "disabled"))
984 }
985
986 fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo {
987 RunInfo {
988 repository: format!("{}/{}", repo.namespace, repo.name),
989 repository_id: repo.id.clone(),
990 default_branch: repo.default_branch.clone(),
991 event_name: event_name.to_owned(),
992 event: subject.payload.clone(),
993 git_ref: subject.git_ref.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'994 sha: subject.merge.clone().unwrap_or_else(|| subject.sha.clone()),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor995 head_ref: subject.head_ref.clone(),
996 base_ref: subject.base_ref.clone(),
997 actor: sender.to_owned(),
998 actor_id: actor_id.unwrap_or_default().to_owned(),
999 triggering_actor: sender.to_owned(),
1000 run_id: String::new(),
1001 run_number: 0,
1002 run_attempt: 1,
1003 workflow: workflow.name.clone().unwrap_or_default(),
1004 workflow_path: String::new(),
1005 server_url: SITE.to_owned(),
1006 api_url: API.to_owned(),
1007 }
1008 }
1009
1010 #[allow(clippy::too_many_arguments)]
1011 async fn record_invalid(
1012 &self,
1013 repo: &Repo,
1014 path: &str,
1015 source: &str,
1016 subject: &Subject,
1017 event_key: &str,
1018 actor_id: Option<&str>,
1019 sender: &str,
1020 problem: &str,
1021 ) -> Result<()> {
1022 let row = self.workflow_row(repo, path, path, source).await?;
1023 self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
1024 }
1025
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1026 /// Scheduled workflows that run this minute, on the default branch: at
1027 /// most every five minutes (`cron::Schedule::runs_at`). Not in a
1028 /// repository with no push for [`crate::SCHEDULE_IDLE_MS`], nor for an
1029 /// hour after billing refused one of the workflow's scheduled jobs
1030 /// ([`crate::SCHEDULE_REFUSED_MS`]): no run is made only to be refused.
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1031 pub async fn run_schedules(&self, minute: u64) -> Result<()> {
1032 let rows = self
1033 .db
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1034 .prepare(
1035 "SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL
1036 AND COALESCE(pushed_at, updated_at) >= ? AND (schedule_refused_until IS NULL OR schedule_refused_until <= ?)",
1037 )
1038 .bind(&[rfc3339(minute.saturating_sub(crate::SCHEDULE_IDLE_MS)).into(), rfc3339(minute).into()])?
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1039 .all()
1040 .await?
1041 .results::<WorkflowRow>()?;
1042 for row in rows {
1043 let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1044 let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.runs_at(minute))) else {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1045 continue;
1046 };
1047 let Ok(workflow) = workflow::parse(&row.source) else { continue };
Merge branch 'mirroring' into artifacts-mode1048 // Schedules wait while a repository is archived, or a mirror runs
1049 // nothing; a deleted one is not found.
1050 let Some((repo, _ws)) = self
1051 .repo_by_id(&row.repo_id)
1052 .await?
1053 .filter(|(repo, _)| !repo.archived() && crate::mirrored::policy(repo.mirror.as_ref(), false).runs)
1054 else {
1055 continue;
1056 };
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.1057 // A managed workflow's row may be out of date while its
1058 // repository waits to be synced again (managed.rs).
1059 if g1t_actions::managed::is_managed(&row.path) && !self.synced(&repo.id).await? {
1060 continue;
1061 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1062 let Some(sha) = self.default_head(&repo).await? else { continue };
1063 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
1064 let mut subject = Subject {
1065 source: Self::repo_path(&repo),
1066 source_ref: None,
1067 git_ref: format!("refs/heads/{}", repo.default_branch),
1068 sha,
1069 head_ref: None,
1070 base_ref: None,
1071 pull: None,
1072 filter_ref: String::new(),
1073 paths: None,
1074 compare: None,
1075 payload,
1076 title: format!("Scheduled: {cron}"),
1077 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1078 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1079 merge: None,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1080 };
1081 subject.filter_ref = subject.git_ref.clone();
1082 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
1083 self.create_run(NewRun {
1084 repo: repo.clone(),
1085 path: row.path.clone(),
1086 source: row.source.clone(),
1087 workflow,
1088 info,
1089 action: None,
1090 pull: None,
1091 title: subject.title.clone(),
1092 inputs: Map::new(),
1093 event_key: format!("schedule:{minute}"),
1094 actor_id: None,
1095 actor: None,
1096 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1097 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1098 head_sha: None,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1099 })
1100 .await?;
1101 }
1102 Ok(())
1103 }
1104
1105 /// `dispatch`: someone with the Write role runs a workflow that has
1106 /// `workflow_dispatch`.
1107 pub async fn dispatch(&self, a: DispatchArgs) -> Result<Outcome<WorkflowRun>> {
1108 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
1109 if repo.archived() {
1110 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1111 }
1112 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1113 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1114 };
1115 let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
1116 let full_ref = if git_ref.starts_with("refs/") {
1117 git_ref.clone()
1118 } else {
1119 // A branch if there is one by that name, otherwise a tag.
1120 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
1121 &self.repos,
1122 "branches",
1123 &g1t_contracts::repos::BranchesArgs {
1124 path: Self::repo_path(&repo),
1125 viewer: Some(ws.clone()),
1126 },
1127 )
1128 .await?;
1129 let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
1130 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
1131 };
1132 let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
Merge branch 'mirroring' into artifacts-mode1133 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
1134 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
1135 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1136 }
1137 let read = self.read_for(&repo, &ws, Some(&short), policy.github).await?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1138 let Some(sha) = read.head.clone() else {
1139 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
1140 };
1141 // A workflow is named by its file (`build.yml`), its path, or its id
1142 // (`wfl_…`), which stands for the path it was read from.
1143 let by_id = if a.workflow.starts_with("wfl_") {
1144 self.db
1145 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?")
1146 .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])?
1147 .first::<WorkflowRow>(None)
1148 .await?
1149 .map(|row| row.path)
1150 } else {
1151 None
1152 };
1153 let named = by_id.as_deref().unwrap_or(&a.workflow);
1154 let wanted = named.trim_start_matches(".g1t/workflows/");
1155 let Some(file) = read.files.iter().find(|file| {
1156 file.path.rsplit('/').next() == Some(wanted) || file.path == named
1157 }) else {
1158 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
1159 };
1160 let workflow = match workflow::parse(&file.source) {
1161 Ok(workflow) => workflow,
1162 Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
1163 };
1164 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
1165 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
1166 };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91167 // `type: environment` names one of the repository's environments.
1168 let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) {
1169 self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect()
1170 } else {
1171 Vec::new()
1172 };
1173 let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments));
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1174 let payload = json!({
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91175 // As GitHub gives them here: text, `true` and `false` included.
1176 // The `inputs` context keeps booleans and numbers.
1177 "inputs": event_inputs(&inputs),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1178 "ref": full_ref,
1179 "repository": payload::repository(&repo),
1180 "sender": payload::user(&a.actor.username),
1181 "workflow": file.path,
1182 });
1183 let subject = Subject {
1184 source: Self::repo_path(&repo),
1185 source_ref: Some(sha.clone()),
1186 git_ref: full_ref.clone(),
1187 sha,
1188 head_ref: None,
1189 base_ref: None,
1190 pull: None,
1191 filter_ref: full_ref,
1192 paths: None,
1193 compare: None,
1194 payload,
1195 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
1196 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1197 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1198 merge: None,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1199 };
1200 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1201 let created = self
1202 .create_run(NewRun {
1203 repo: repo.clone(),
1204 path: file.path.clone(),
1205 source: file.source.clone(),
1206 workflow,
1207 info,
1208 action: None,
1209 pull: None,
1210 title: subject.title.clone(),
1211 inputs,
1212 event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
1213 actor_id: Some(a.actor.id.clone()),
1214 actor: Some(a.actor.username.clone()),
1215 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1216 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1217 head_sha: None,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1218 })
1219 .await?;
1220 match created {
1221 Some(id) => self.run_summary(&id).await,
1222 None => Ok(fail(FailureCode::Conflict, "It did not start.")),
1223 }
1224 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1225
1226 /// `repository_dispatch`: an outside event, by name, starts the default
1227 /// branch's workflows that run `on: repository_dispatch` with that type
1228 /// (or with no `types`). A workflow job's token may send one: this,
1229 /// with `workflow_dispatch`, is how a workflow starts another.
1230 pub async fn repository_dispatch(&self, a: RepositoryDispatchArgs) -> Result<Outcome<u32>> {
1231 let repo = check!(self.may(&a.actor, &a.repo, Capability::Push).await?);
1232 if repo.archived() {
1233 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1234 }
1235 let event_type = a.event_type.trim().to_owned();
1236 if event_type.is_empty() || event_type.chars().count() > 100 {
1237 return Ok(fail(FailureCode::Invalid, "event_type is 1 to 100 characters."));
1238 }
1239 let client_payload = match a.client_payload {
1240 Value::Null => json!({}),
1241 Value::Object(map) if map.len() <= 10 => Value::Object(map),
1242 Value::Object(_) => return Ok(fail(FailureCode::Invalid, "client_payload has at most 10 top-level properties.")),
1243 _ => return Ok(fail(FailureCode::Invalid, "client_payload is a JSON object.")),
1244 };
1245 if serde_json::to_string(&client_payload).map_or(0, |text| text.len()) > 64 * 1024 {
1246 return Ok(fail(FailureCode::Invalid, "client_payload is at most 64 KB."));
1247 }
1248 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1249 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1250 };
Merge branch 'mirroring' into artifacts-mode1251 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
1252 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
1253 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1254 }
1255 let read = self.read_for(&repo, &ws, Some(&repo.default_branch), policy.github).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1256 let Some(sha) = read.head.clone() else {
1257 return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet."));
1258 };
1259 let git_ref = format!("refs/heads/{}", repo.default_branch);
1260 let payload = json!({
1261 "action": event_type,
1262 "branch": repo.default_branch,
1263 "client_payload": client_payload,
1264 "repository": payload::repository(&repo),
1265 "sender": payload::user(&a.actor.username),
1266 });
1267 let key = format!("repository_dispatch:{}", new_id("dsp", now_ms()));
1268 let mut started = 0u32;
1269 for file in read.files {
1270 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1271 let Some(trigger) = workflow.trigger("repository_dispatch") else { continue };
1272 if !trigger.wants_type(Some(&event_type)) || self.disabled(&repo.id, &file.path).await? {
1273 continue;
1274 }
1275 let subject = Subject {
1276 source: Self::repo_path(&repo),
1277 source_ref: Some(sha.clone()),
1278 git_ref: git_ref.clone(),
1279 sha: sha.clone(),
1280 head_ref: None,
1281 base_ref: None,
1282 pull: None,
1283 filter_ref: git_ref.clone(),
1284 paths: None,
1285 compare: None,
1286 payload: payload.clone(),
1287 title: event_type.clone(),
1288 trusted: true,
1289 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1290 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1291 };
1292 let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1293 let created = self
1294 .create_run(NewRun {
1295 repo: repo.clone(),
1296 path: file.path.clone(),
1297 source: file.source.clone(),
1298 workflow,
1299 info,
1300 action: Some(event_type.clone()),
1301 pull: None,
1302 title: subject.title.clone(),
1303 inputs: Map::new(),
1304 event_key: key.clone(),
1305 actor_id: Some(a.actor.id.clone()),
1306 actor: Some(a.actor.username.clone()),
1307 trusted: true,
1308 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1309 head_sha: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1310 })
1311 .await?;
1312 if created.is_some() {
1313 started += 1;
1314 }
1315 }
1316 Ok(Outcome::Ok(started))
1317 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1318}
1319
1320#[derive(serde::Deserialize)]
1321#[serde(rename_all = "camelCase")]
1322pub struct MergeGroupArgs {
1323 pub repo_id: String,
1324 pub entry: String,
1325 pub sha: String,
1326 pub head_ref: String,
1327 #[serde(default)]
1328 pub base_sha: Option<String>,
1329 pub number: u32,
1330 #[serde(default)]
1331 pub ahead: Vec<u32>,
1332}
1333
1334impl Actions {
1335 /// `merge_group`: the merge queue built a state and its checks passed.
1336 /// Starts the workflows that run `on: merge_group` on it, as GitHub's
1337 /// queue does, and says how many started; the queue waits for their
1338 /// statuses on that commit.
1339 pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
1340 let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
1341 return Ok(Outcome::Ok(json!({ "runs": 0 })));
1342 };
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.1343 let mut read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
1344 // A required workflow's check holds the queue too (managed.rs).
1345 read.files = self.with_managed(&repo, read.files).await?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1346 let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
1347 let payload = json!({
1348 "action": "checks_requested",
1349 "merge_group": {
1350 "head_sha": a.sha,
1351 "head_ref": a.head_ref,
1352 "base_sha": a.base_sha,
1353 "base_ref": format!("refs/heads/{}", repo.default_branch),
1354 "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
1355 },
1356 "repository": payload::repository(&repo),
1357 "sender": payload::user(&repo.namespace),
1358 });
1359 let mut started = 0u32;
1360 for file in read.files {
1361 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1362 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.1363 if !trigger.wants_type(Some("checks_requested")) || (!file.required && self.disabled(&repo.id, &file.path).await?) {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1364 continue;
1365 }
1366 // Branch filters on merge_group name the branch it merges into.
1367 if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
1368 continue;
1369 }
1370 let ahead = if a.ahead.is_empty() {
1371 String::new()
1372 } else {
1373 format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
1374 };
1375 let subject = Subject {
1376 source: Self::repo_path(&repo),
1377 source_ref: Some(a.sha.clone()),
1378 git_ref: a.head_ref.clone(),
1379 sha: a.sha.clone(),
1380 head_ref: None,
1381 base_ref: Some(repo.default_branch.clone()),
1382 pull: Some(a.number),
1383 filter_ref: format!("refs/heads/{}", repo.default_branch),
1384 paths: None,
1385 compare: None,
1386 payload: payload.clone(),
1387 title: format!("Merge queue: #{}{ahead}", a.number),
1388 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1389 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1390 merge: None,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1391 };
1392 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
1393 let created = self
1394 .create_run(NewRun {
1395 repo: repo.clone(),
1396 path: file.path.clone(),
1397 source: file.source.clone(),
1398 workflow,
1399 info,
1400 action: Some("checks_requested".to_owned()),
1401 pull: Some(a.number),
1402 title: subject.title.clone(),
1403 inputs: Map::new(),
1404 event_key: format!("merge_group:{}:{}", a.entry, a.sha),
1405 actor_id: None,
1406 actor: None,
1407 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1408 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1409 head_sha: None,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1410 })
1411 .await?;
1412 if created.is_some() {
1413 started += 1;
1414 }
1415 }
1416 Ok(Outcome::Ok(json!({ "runs": started })))
1417 }
1418}
1419
1420/// The inputs of a manual run: what was given, checked against the
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91421/// workflow's declared inputs, with their defaults filled in. An input the
1422/// workflow does not declare is refused, as GitHub refuses it.
1423/// `environments` are the repository's, for `type: environment`.
1424fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> {
1425 let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect();
1426 if !unexpected.is_empty() {
1427 let names = serde_json::to_string(&unexpected).unwrap_or_default();
1428 let declared = if trigger.inputs.is_empty() {
1429 "The workflow declares no inputs.".to_owned()
1430 } else {
1431 format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", "))
1432 };
1433 return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}"));
1434 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1435 let mut inputs = Map::new();
1436 for (name, spec) in &trigger.inputs {
1437 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
1438 let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
1439 let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
1440 let value = match value {
1441 Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
1442 Some(Value::Null) | None => match kind {
1443 "boolean" => Value::Bool(false),
1444 _ => Value::String(String::new()),
1445 },
1446 Some(value) => match kind {
1447 "boolean" => Value::Bool(match &value {
1448 Value::Bool(flag) => *flag,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91449 Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true,
1450 Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false,
1451 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1452 }),
1453 "number" => match &value {
1454 Value::Number(_) => value,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91455 Value::String(text) => match number(text) {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1456 Some(number) => Value::Number(number),
1457 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1458 },
1459 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1460 },
1461 "choice" => {
1462 let text = g1t_actions::expr::to_text(&value);
1463 let options: Vec<String> =
1464 spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
1465 if !options.is_empty() && !options.contains(&text) {
1466 return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
1467 }
1468 Value::String(text)
1469 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91470 "environment" => {
1471 let text = g1t_actions::expr::to_text(&value);
1472 let wanted = text.trim().to_ascii_lowercase();
1473 if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) {
1474 return fail(
1475 FailureCode::Invalid,
1476 format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()),
1477 );
1478 }
1479 Value::String(text)
1480 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1481 _ => Value::String(g1t_actions::expr::to_text(&value)),
1482 },
1483 };
1484 inputs.insert(name.clone(), value);
1485 }
1486 Outcome::Ok(inputs)
1487}
1488
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91489/// A number input given as text: a whole number stays one.
1490fn number(text: &str) -> Option<serde_json::Number> {
1491 let text = text.trim();
1492 match text.parse::<i64>() {
1493 Ok(whole) => Some(whole.into()),
1494 Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64),
1495 }
1496}
1497
1498/// A manual run's inputs as `github.event.inputs` gives them: every one as
1499/// text, as GitHub's `workflow_dispatch` payload has them.
1500fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> {
1501 inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect()
1502}
1503
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1504#[cfg(test)]
1505mod tests {
1506 use super::*;
1507 use g1t_contracts::work::{Pull, g1t_author};
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1508
1509 #[test]
1510 fn every_event_a_workflow_runs_on_is_sent_to_this_queue() {
1511 for kind in g1t_contracts::webhooks::EVENT_TYPES {
1512 if !github_events(kind).is_empty() {
1513 assert!(
1514 g1t_contracts::subscribers::routed("SUBSCRIBER_ACTIONS", kind),
1515 "{kind} starts workflows but is not routed to actions (g1t_contracts::subscribers)"
1516 );
1517 }
1518 }
1519 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1520 use g1t_contracts::{Membership, PrincipalKind};
1521
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1522 #[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.1523 fn a_pushers_email_is_their_noreply_address() {
1524 assert_eq!(pusher_email(Some("usr_01j9zq4m8x7k2v5n3b6c1d0efg"), "Ada").as_deref(), Some("6c1d0efg+ada@users.noreply.g1t.sh"));
1525 assert_eq!(pusher_email(Some(AGENT_ID), AGENT_NAME).as_deref(), Some(g1t_contracts::system::EMAIL));
1526 assert_eq!(pusher_email(Some("wsp_1"), "acme"), None);
1527 assert_eq!(pusher_email(None, "acme"), None);
1528 }
1529
1530 #[test]
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1531 fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() {
1532 let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
1533 let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
1534 let unavailable = PullMergeRef::Unavailable { reason: "too large".into() };
1535 assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into()));
1536 assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into()));
1537 // GitHub runs no pull_request workflow while it conflicts.
1538 for action in ["opened", "synchronize", "reopened", "edited"] {
1539 assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing);
1540 }
1541 // Closing it, or reviewing it, still runs, on the head.
1542 assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head);
1543 assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head);
1544 // What g1t could not merge itself runs on the head.
1545 assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head);
1546 }
1547
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1548 fn repo() -> Repo {
1549 serde_json::from_value(json!({
1550 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
1551 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
1552 }))
1553 .unwrap()
1554 }
1555
1556 fn person(id: &str, username: &str) -> User {
1557 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
1558 }
1559
1560 fn made_for(asker: User) -> Pull {
1561 serde_json::from_value(json!({
1562 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
1563 "agent": "g1t", "runtime": "hosted", "status": "open",
1564 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
1565 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
1566 "supersededBy": null, "checkStatus": null,
1567 "author": g1t_author(), "requestedBy": asker,
1568 "createdAt": "", "updatedAt": ""
1569 }))
1570 .unwrap()
1571 }
1572
1573 #[test]
1574 fn g1t_s_change_for_someone_is_trusted_as_they_are() {
1575 // Stored people carry no memberships, so identity is asked about
1576 // them; being g1t's change gives it nothing more.
1577 let pull = made_for(person("usr_2", "ana"));
1578 assert!(!trusted_outright(pull.owner(), &repo()));
1579 // Someone known to be able to push is trusted at once.
1580 let mut member = person("usr_1", "syntaqx");
1581 member.workspaces.push(Membership::member("acme"));
1582 let pull = made_for(member);
1583 assert!(trusted_outright(pull.owner(), &repo()));
1584 }
1585
1586 #[test]
1587 fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() {
1588 let pull = made_for(person("usr_1", "syntaqx"));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1589 let event = payload::pull(&repo(), &pull);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1590 assert_eq!(event["user"]["login"], "g1t");
1591 assert_eq!(event["user"]["type"], "Bot");
1592 assert_eq!(event["requested_by"]["login"], "syntaqx");
1593 assert_eq!(event["requested_by"]["type"], "User");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1594 let as_issue = payload::pull_as_issue(&repo(), &pull);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1595 assert_eq!(as_issue["user"]["login"], "g1t");
1596 assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
1597 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1598
1599 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1600 fn releases_deployments_and_deleted_comments_read_as_githubs() {
1601 let release = payload::release(
1602 &repo(),
1603 &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false,
1604 "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }),
1605 );
1606 assert_eq!(release["tag_name"], "v1.2.0");
1607 assert_eq!(release["name"], "v1.2.0");
1608 assert_eq!(release["prerelease"], true);
1609 assert_eq!(release["author"]["login"], "ana");
1610 assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0");
1611 let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana",
1612 "production_environment": false, "created_at": "t", "updated_at": "t" });
1613 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);
1614 assert_eq!(status["state"], "success");
1615 assert_eq!(status["environment"], "staging");
1616 assert_eq!(status["environment_url"], "https://s.example");
1617 assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1618 let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1619 assert_eq!(gone["user"]["login"], "bo");
1620 assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1621 assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
1622 assert!(!is_commit("main"));
1623 }
1624
1625 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1626 fn a_pull_request_names_its_own_base_labels_and_milestone() {
1627 let mut pull = made_for(person("usr_1", "syntaqx"));
1628 let event = payload::pull(&repo(), &pull);
1629 assert_eq!(event["base"]["ref"], repo().default_branch);
1630 pull.base = Some("release/1.x".into());
1631 pull.labels = vec!["bug".into()];
1632 pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() });
1633 let event = payload::pull(&repo(), &pull);
1634 assert_eq!(event["base"]["ref"], "release/1.x");
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1635 assert_eq!(event["labels"][0]["name"], "bug");
1636 assert!(event["labels"][0]["id"].is_u64());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1637 assert_eq!(event["milestone"]["title"], "1.1");
1638 let mut labeled = serde_json::json!({ "action": "labeled" });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1639 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 bar1640 assert_eq!(labeled["label"]["color"], "d73a4a");
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91641 let mut assigned = serde_json::json!({ "action": "assigned" });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1642 payload::changed(&repo(), &mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91643 assert_eq!(assigned["assignee"]["login"], "bo");
1644 assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] })));
1645 assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
1646 }
1647
1648 fn dispatchable(yaml: &str) -> Trigger {
1649 workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone()
1650 }
1651
1652 fn given(value: Value) -> Map<String, Value> {
1653 value.as_object().unwrap().clone()
1654 }
1655
1656 const DISPATCH: &str = "on:
1657 workflow_dispatch:
1658 inputs:
1659 dry-run: { type: boolean, default: false }
1660 count: { type: number, default: 2 }
1661 target: { type: environment }
1662 level: { type: choice, options: [low, high], default: low }
1663 note: { required: false }
1664jobs:
1665 a:
1666 runs-on: x
1667 steps: [{ run: 'true' }]";
1668
1669 #[test]
1670 fn an_input_the_workflow_does_not_declare_is_refused() {
1671 let trigger = dispatchable(DISPATCH);
1672 let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else {
1673 panic!("an undeclared input was taken");
1674 };
1675 assert_eq!(failure.code, FailureCode::Invalid);
1676 assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message);
1677 assert!(failure.message.contains("`dry-run`"));
1678 let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]");
1679 let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() };
1680 assert!(failure.message.contains("declares no inputs"));
1681 assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty()));
1682 }
1683
1684 #[test]
1685 fn inputs_are_typed_and_the_event_has_them_as_text() {
1686 let trigger = dispatchable(DISPATCH);
1687 let environments = vec!["staging".to_owned(), "production".to_owned()];
1688 let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments)
1689 else {
1690 panic!("refused");
1691 };
1692 assert_eq!(inputs["dry-run"], json!(true));
1693 assert_eq!(inputs["count"], json!(3));
1694 assert_eq!(inputs["level"], json!("low"));
1695 assert_eq!(inputs["note"], json!(""));
1696 let event = event_inputs(&inputs);
1697 assert_eq!(event["dry-run"], json!("true"));
1698 assert_eq!(event["count"], json!("3"));
1699 assert_eq!(event["target"], json!("Staging"));
1700 let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() };
1701 assert_eq!(event_inputs(&defaults)["dry-run"], json!("false"));
1702 assert_eq!(defaults["count"], json!(2));
1703 let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) {
1704 Outcome::Fail(failure) => failure.message,
1705 Outcome::Ok(_) => panic!("taken"),
1706 };
1707 assert!(refused(json!({ "target": "qa" })).contains("none called `qa`"));
1708 assert!(refused(json!({ "dry-run": "yes" })).contains("true or false"));
1709 assert!(refused(json!({ "count": "many" })).contains("is a number"));
1710 assert!(refused(json!({ "level": "mid" })).contains("low, high"));
1711 }
1712
1713 #[test]
1714 fn create_and_delete_take_no_filters_and_name_their_ref() {
1715 assert!(unfiltered("create") && unfiltered("delete"));
1716 assert!(!unfiltered("push") && !unfiltered("pull_request"));
1717 assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x")));
1718 assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0")));
1719 assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None);
1720 assert_eq!(branch_or_tag("refs/notes/commits"), None);
1721 // A workflow can name `delete`. It takes no filters, as GitHub's
1722 // schema says, so writing one is refused when the workflow is read.
1723 assert!(workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").is_err());
1724 let workflow = workflow::parse("on:\n delete:\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1725 let trigger = workflow.trigger("delete").unwrap();
1726 assert!(trigger.wants_type(None));
1727 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 bar1728 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1729}

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