Skip to content

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

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

Files pages say Files in the trail and keep the description in About only1//! 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};
Files pages say Files in the trail and keep the description in About only10use 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};
Files pages say Files in the trail and keep the description in About only13use g1t_contracts::{FailureCode, Outcome, User, new_id};
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails14use g1t_contracts::time::rfc3339;
Files pages say Files in the trail and keep the description in About only15use 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
Files pages say Files in the trail and keep the description in About only27/// 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>,
Files pages say Files in the trail and keep the description in About only54}
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
Files pages say Files in the trail and keep the description in About only130/// Whether whoever a pull request is for is trusted without asking
The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were.131/// identity: someone whose role here is known to allow pushing.
Files pages say Files in the trail and keep the description in About only132fn trusted_outright(owner: &User, repo: &Repo) -> bool {
The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were.133 access::can(Some(owner), repo, Capability::Push)
Files pages say Files in the trail and keep the description in About only134}
135
136impl Actions {
137 async fn username(&self, id: Option<&str>) -> Result<Option<String>> {
138 let Some(id) = id else { return Ok(None) };
139 if id == AGENT_ID {
140 return Ok(Some(AGENT_NAME.to_owned()));
141 }
142 let names: std::collections::HashMap<String, String> =
143 g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.to_owned()] }).await?;
144 Ok(names.get(id).cloned())
145 }
146
147 /// Whether whoever a pull request is for (Pull::owner: whoever asked
148 /// g1t for it, or its author) could push to the repository, so its
149 /// runs get the secrets and a token. Anyone else's, a reader's included
150 /// (who may open one on a private repository too), runs without them.
151 /// A change g1t made for someone is trusted as they are.
152 async fn insider(&self, owner: &User, repo: &Repo, ws: &User) -> Result<bool> {
153 if trusted_outright(owner, repo) {
154 return Ok(true);
155 }
156 // Stored authors carry no memberships or grants: ask identity, as
157 // the workspace (which may see anyone's permission).
158 let permission: Outcome<access::PermissionInfo> = g1t_kit::call(
159 &self.identity,
160 "collaborator_permission",
161 &access::CollaboratorPermissionArgs {
162 viewer: Some(ws.clone()),
163 path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
164 username: owner.username.clone(),
165 },
166 )
167 .await?;
168 Ok(permission
169 .into_result()
170 .ok()
171 .and_then(|info| info.role)
172 .is_some_and(|role| access::allows(role, Capability::Push)))
173 }
174
175 async fn commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<Vec<Commit>> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'176 Ok(self.pushed_commits(repo, actor, after, before).await?.0)
177 }
178
179 /// The commits a push added, oldest first, and whether it was forced:
180 /// `before` is not in `after`'s history. Known only when that history
181 /// is read whole (it is shorter than the [`PUSH_LOG`] commits read);
182 /// a longer one says not forced.
183 async fn pushed_commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<(Vec<Commit>, bool)> {
Files pages say Files in the trail and keep the description in About only184 let log: Outcome<Vec<Commit>> = g1t_kit::call(
185 &self.repos,
186 "log",
187 &LogArgs {
188 path: RepoPath {
189 namespace: repo.namespace.clone(),
190 name: repo.name.clone(),
191 },
192 viewer: Some(actor.clone()),
193 git_ref: Some(after.to_owned()),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'194 limit: PUSH_LOG,
Files pages say Files in the trail and keep the description in About only195 },
196 )
197 .await?;
198 let mut commits: Vec<Commit> = log.into_result().unwrap_or_default();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'199 let whole = commits.len() < PUSH_LOG as usize;
200 let mut forced = false;
201 if let Some(before) = before.filter(|before| *before != payload::ZERO) {
202 match commits.iter().position(|commit| commit.hash == before) {
203 Some(at) => commits.truncate(at),
204 None => forced = whole && !commits.is_empty(),
205 }
Files pages say Files in the trail and keep the description in About only206 }
207 // GitHub lists them oldest first, with the head commit last.
208 commits.reverse();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'209 Ok((commits, forced))
Files pages say Files in the trail and keep the description in About only210 }
211
What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so.212 /// The message of the commit `sha` in `path`, when it can be read.
213 async fn head_message(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<String>> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'214 Ok(self.commit_at(path, actor, sha).await?.map(|commit| commit.message))
215 }
216
217 /// The commit `sha` in `path`, when it can be read.
218 async fn commit_at(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<Commit>> {
What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so.219 let log: Outcome<Vec<Commit>> = g1t_kit::call(
220 &self.repos,
221 "log",
222 &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 },
223 )
224 .await?;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'225 Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next()))
What runs differently on g1t's machines is said before it surprises anyone, and four things now behave as on GitHub. A commit whose message says `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`, or ends with `skip-checks: true`, starts no `push` or `pull_request` workflows. A `pull_request_review` event's `review.state` is `approved` or `changes_requested`, not `approve` or `requestchanges`. A self-hosted job that names no `timeout-minutes` runs for 360 minutes, not 60. A JavaScript action's `::debug::` lines show on a debug run, as `run:` steps' do. The workflow's page now warns when a job asks for more than the 60 minutes g1t's machines give it, when an Arm label will run on x64, and when a matrix on a job that calls a reusable workflow will run it once; it no longer says artifacts are kept up to 60 MB (they are kept up to 5 GiB), nor that `setup-node` with `cache:` runs without its cache (the toolkit's cache works). The desktop release's two hosted jobs ask for the 60 minutes they get. The Actions and self-hosted runners guides say so.226 }
227
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.228 /// What `head` changes from `base`: each path and how.
229 async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<(String, FileStatus)>> {
Files pages say Files in the trail and keep the description in About only230 let compared: Outcome<Comparison> = g1t_kit::call(
231 &self.repos,
232 "compare",
233 &CompareArgs {
234 repo_id: repo.id.clone(),
235 viewer: Some(actor.clone()),
236 base,
237 head: Some(head),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar238 base_branch: None,
Files pages say Files in the trail and keep the description in About only239 },
240 )
241 .await?;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.242 Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| (f.path, f.status)).collect()).unwrap_or_default())
243 }
244
245 /// The repository's labels, for their colors, when `payload` has any.
246 async fn label_colors(&self, repo: &Repo, ws: &User, payload: &mut Value) -> Result<()> {
247 if !payload::has_labels(payload) {
248 return Ok(());
249 }
250 let args = ViewArgs { repo: Self::repo_path(repo), number: 0, viewer: Some(ws.clone()), after_seq: 0 };
251 let labels: Outcome<Vec<Label>> = g1t_kit::call(&self.work, "list_labels", &args).await?;
252 if let Outcome::Ok(labels) = labels {
253 payload::color_labels(payload, &labels);
254 }
255 Ok(())
Files pages say Files in the trail and keep the description in About only256 }
257
258 async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'259 self.branch_head(repo, &repo.default_branch).await
260 }
261
262 /// The commit `branch` points to now.
263 async fn branch_head(&self, repo: &Repo, branch: &str) -> Result<Option<String>> {
Files pages say Files in the trail and keep the description in About only264 g1t_kit::call(
265 &self.repos,
266 "head",
267 &g1t_contracts::repos::HeadArgs {
268 repo_id: repo.id.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'269 branch: branch.to_owned(),
Files pages say Files in the trail and keep the description in About only270 },
271 )
272 .await
273 }
274
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'275 /// The pull request's test merge commit for `head` (repos
276 /// `pull_merge_ref`), made or moved now. A failure to ask is
277 /// unavailable, so that its runs still start, on the head.
278 async fn test_merge(&self, repo: &Repo, pull: &g1t_contracts::work::Pull, head: &str, base_ref: &str) -> PullMergeRef {
279 let asked: Result<PullMergeRef> = g1t_kit::call(
280 &self.repos,
281 "pull_merge_ref",
282 &PullMergeRefArgs {
283 repo_id: repo.id.clone(),
284 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
285 head: head.to_owned(),
286 number: pull.number,
287 target_branch: Some(base_ref.to_owned()),
288 },
289 )
290 .await;
291 asked.unwrap_or_else(|error| PullMergeRef::Unavailable { reason: error.to_string() })
292 }
293
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts294 /// Whether `name` is one of the repository's branches.
295 async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> {
296 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
297 &self.repos,
298 "branches",
299 &g1t_contracts::repos::BranchesArgs {
300 path: Self::repo_path(repo),
301 viewer: Some(ws.clone()),
302 },
303 )
304 .await?;
305 Ok(branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == name))
306 }
307
Files pages say Files in the trail and keep the description in About only308 fn repo_path(repo: &Repo) -> RepoPath {
309 RepoPath {
310 namespace: repo.namespace.clone(),
311 name: repo.name.clone(),
312 }
313 }
314
315 /// The subject of an event of `kind`, as GitHub's `event_name`.
316 async fn subject(&self, event: &Event, event_name: &str, action: Option<&str>, repo: &Repo, ws: &User, sender: &str) -> Result<Option<Subject>> {
317 let path = Self::repo_path(repo);
318 let data = &event.data;
319 let on_default = |sha: String, payload: Value, title: String, pull: Option<u32>| Subject {
320 source: path.clone(),
321 source_ref: None,
322 git_ref: format!("refs/heads/{}", repo.default_branch),
323 sha,
324 head_ref: None,
325 base_ref: None,
326 pull,
327 filter_ref: format!("refs/heads/{}", repo.default_branch),
328 paths: None,
329 compare: None,
330 payload,
331 title,
332 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'333 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'334 merge: None,
Files pages say Files in the trail and keep the description in About only335 };
336 let view = |number: u32| ViewArgs {
337 repo: path.clone(),
338 number,
339 viewer: Some(ws.clone()),
340 after_seq: 0,
341 };
342 Ok(match event_name {
343 "push" => {
344 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
345 return Ok(None);
346 };
347 // The merge queue's states run merge_group workflows, not push ones.
348 if git_ref.starts_with("refs/heads/g1t-queue/") {
349 return Ok(None);
350 }
351 let before = data["before"].as_str();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'352 let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?;
Files pages say Files in the trail and keep the description in About only353 let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.354 let email = pusher_email(event.actor.as_deref(), sender);
355 let payload = payload::push(repo, git_ref, before, after, &commits, sender, email.as_deref(), forced);
Files pages say Files in the trail and keep the description in About only356 Some(Subject {
357 source: path.clone(),
358 source_ref: Some(after.to_owned()),
359 git_ref: git_ref.to_owned(),
360 sha: after.to_owned(),
361 head_ref: None,
362 base_ref: None,
363 pull: None,
364 filter_ref: git_ref.to_owned(),
365 paths: None,
366 compare: Some((before.map(str::to_owned), after.to_owned())),
367 payload,
368 title,
369 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'370 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'371 merge: None,
Files pages say Files in the trail and keep the description in About only372 })
373 }
Merge branch 'worktree-agent-a3abfcce648e87dca'374 // A branch or tag was made: its own commit, as on GitHub.
375 "create" => {
376 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
377 return Ok(None);
378 };
379 if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() {
380 return Ok(None);
381 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9382 let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
Merge branch 'worktree-agent-a3abfcce648e87dca'383 let payload = json!({
384 "ref": name,
385 "ref_type": ref_type,
386 "master_branch": repo.default_branch,
387 "description": repo.description,
388 "pusher_type": "user",
389 "repository": payload::repository(repo),
390 "sender": payload::user(sender),
391 });
392 Some(Subject {
393 source: path.clone(),
394 source_ref: Some(after.to_owned()),
395 git_ref: git_ref.to_owned(),
396 sha: after.to_owned(),
397 head_ref: None,
398 base_ref: None,
399 pull: None,
400 filter_ref: git_ref.to_owned(),
401 paths: None,
402 compare: None,
403 payload,
404 title: format!("Created {ref_type} {name}"),
405 trusted: true,
406 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'407 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'408 })
409 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9410 // A branch or tag was deleted: on the default branch's latest
411 // commit, as on GitHub, since what was deleted has no commit to
412 // run on now.
413 "delete" => {
414 let Some(git_ref) = data["ref"].as_str() else { return Ok(None) };
415 let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
416 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
417 let payload = json!({
418 "ref": name,
419 "ref_type": ref_type,
420 "pusher_type": "user",
421 "repository": payload::repository(repo),
422 "sender": payload::user(sender),
423 });
424 Some(on_default(sha, payload, format!("Deleted {ref_type} {name}"), None))
425 }
Files pages say Files in the trail and keep the description in About only426 "pull_request" | "pull_request_target" | "pull_request_review" => {
427 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9428 // Assignees changed but nobody was added: someone was only
429 // taken off, which is not `assigned`.
430 if action == Some("assigned") && nobody_added(data) {
431 return Ok(None);
432 }
Files pages say Files in the trail and keep the description in About only433 let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
434 let Outcome::Ok(detail) = detail else { return Ok(None) };
435 let pull = &detail.pull;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar436 let base_ref = pull.base_branch(&repo.default_branch).to_owned();
Files pages say Files in the trail and keep the description in About only437 let mut payload = json!({
438 "action": action,
439 "number": pull.number,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar440 "pull_request": payload::pull(repo, pull),
Files pages say Files in the trail and keep the description in About only441 "repository": payload::repository(repo),
442 "sender": payload::user(sender),
443 });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'444 payload::changed(repo, &mut payload, data);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.445 self.label_colors(repo, ws, &mut payload).await?;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'446 // `base.sha` is the base branch's tip now, as GitHub gives
447 // it, not where the two last agreed.
448 if let Some(tip) = self.branch_head(repo, &base_ref).await? {
449 payload["pull_request"]["base"]["sha"] = json!(tip);
450 }
451 // A merge's commit, once it has landed.
452 if pull.status == g1t_contracts::work::PullStatus::Merged
453 && let Some(commit) = data["commit"].as_str()
454 {
455 payload["pull_request"]["merge_commit_sha"] = json!(commit);
456 }
Files pages say Files in the trail and keep the description in About only457 if event_name == "pull_request_review" {
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar458 let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory);
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'459 payload["review"] = payload::review(repo, pull, review);
Files pages say Files in the trail and keep the description in About only460 }
461 let trusted = self.insider(pull.owner(), repo, ws).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'462 // A pull request from outside may wait for approval before
463 // its head's code runs. `pull_request_target` runs the
464 // base's code, and a merged one's run the commit it landed
465 // as, so neither waits.
466 let approval = if event_name != "pull_request_target" && action != Some("closed") {
467 self.approval_needed(repo, pull.owner(), ws).await?
468 } else {
469 None
470 };
Files pages say Files in the trail and keep the description in About only471 let head_ref = payload::head_ref(pull);
472 if event_name == "pull_request_target" {
473 // In the base's context: its workflows, its head.
474 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
475 let mut subject = on_default(sha, payload, pull.title.clone(), Some(pull.number));
476 subject.head_ref = Some(head_ref);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar477 subject.base_ref = Some(base_ref.clone());
478 subject.filter_ref = format!("refs/heads/{base_ref}");
Files pages say Files in the trail and keep the description in About only479 subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect());
480 return Ok(Some(subject));
481 }
482 // A merged pull request's run is on the commit it landed as,
483 // in the repository; otherwise on its head, where that is.
484 let landed = match (action, data["commit"].as_str()) {
485 (Some("closed"), Some(commit)) => Some(commit.to_owned()),
486 _ => None,
487 };
488 let sha = match (&landed, data["commit"].as_str(), &pull.head_commit) {
489 (Some(commit), _, _) => commit.clone(),
490 (None, Some(commit), _) => commit.to_owned(),
491 (None, None, Some(head)) => head.clone(),
492 (None, None, None) => return Ok(None),
493 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'494 // Otherwise it builds the head merged into the base's tip,
495 // as on GitHub: the test merge commit at refs/pull/<n>/merge,
496 // in the repository, whose workflows it reads.
497 let merge = match landed {
498 Some(_) => None,
499 None => {
500 let made = self.test_merge(repo, pull, &sha, &base_ref).await;
501 match pull_build(event_name, action, &made) {
502 PullBuild::Merge(merge) => Some(merge),
503 PullBuild::Nothing => {
504 worker::console_log!("actions: pull request #{} conflicts with {base_ref}: {made:?}; no pull_request workflows run", pull.number);
505 return Ok(None);
506 }
507 PullBuild::Head => {
508 worker::console_log!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number);
509 None
510 }
511 }
512 }
Files pages say Files in the trail and keep the description in About only513 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'514 if let Some(merge) = &merge {
515 payload["pull_request"]["merge_commit_sha"] = json!(merge);
516 }
517 let (source, source_ref) = match (&landed, &merge) {
518 (Some(_), _) => (path.clone(), sha.clone()),
519 (None, Some(merge)) => (path.clone(), merge.clone()),
520 (None, None) => (pull.fork.clone().unwrap_or_else(|| path.clone()), sha.clone()),
521 };
Files pages say Files in the trail and keep the description in About only522 Some(Subject {
523 source,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'524 source_ref: Some(source_ref),
525 merge,
Files pages say Files in the trail and keep the description in About only526 git_ref: format!("refs/pull/{}/merge", pull.number),
527 sha,
528 head_ref: Some(head_ref),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar529 base_ref: Some(base_ref.clone()),
Files pages say Files in the trail and keep the description in About only530 pull: Some(pull.number),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar531 // `branches` filters on pull requests name the base.
532 filter_ref: format!("refs/heads/{base_ref}"),
Files pages say Files in the trail and keep the description in About only533 paths: Some(pull.files.iter().map(|f| f.path.clone()).collect()),
534 compare: None,
535 payload,
536 title: pull.title.clone(),
537 trusted,
Merge branch 'worktree-agent-a3abfcce648e87dca'538 approval,
Files pages say Files in the trail and keep the description in About only539 })
540 }
541 "workflow_run" => {
542 // A run of a workflow_run workflow does not start another,
543 // so two such workflows cannot set each other off.
544 if data["event"].as_str() == Some("workflow_run") {
545 return Ok(None);
546 }
547 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
548 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
549 let name = data["workflow"].as_str().unwrap_or_default();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'550 // The followed run's row, for what the event does not carry.
551 let row = match data["runId"].as_str() {
552 Some(id) => self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<crate::plan::RunRow>(None).await?,
553 None => None,
554 };
555 let head_commit = match data["sha"].as_str() {
556 Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head),
557 None => None,
558 };
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.559 // Its pull request, for `pull_requests[]`.
560 let pull = match data["pull"].as_u64() {
561 Some(number) => match g1t_kit::call::<_, Outcome<PullDetail>>(&self.work, "get_pull", &view(number as u32)).await? {
562 Outcome::Ok(detail) => Some(detail.pull),
563 Outcome::Fail(_) => None,
564 },
565 None => None,
566 };
567 let base_sha = match &pull {
568 Some(pull) if pull.fork.is_none() => self.branch_head(repo, pull.base_branch(&repo.default_branch)).await?,
569 _ => None,
570 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'571 let followed = payload::FollowedRun {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.572 pull: pull.as_ref(),
573 base_sha: base_sha.as_deref(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'574 data,
575 workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()),
576 attempt: row.as_ref().map(|r| r.attempt),
577 created_at: row.as_ref().map(|r| r.created_at.as_str()),
578 started_at: row.as_ref().and_then(|r| r.started_at.as_deref()),
579 updated_at: row.as_ref().and_then(|r| r.finished_at.as_deref()),
580 actor: row.as_ref().and_then(|r| r.actor.as_deref()),
581 triggering_actor: row.as_ref().and_then(|r| r.triggering_actor.as_deref()),
582 title: row.as_ref().map(|r| r.title.as_str()),
583 head_commit: head_commit.as_ref(),
584 };
Files pages say Files in the trail and keep the description in About only585 let payload = json!({
586 "action": "completed",
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'587 "workflow_run": payload::workflow_run(repo, &followed),
588 "workflow": {
589 "id": followed.workflow_id,
590 "node_id": followed.workflow_id,
Files pages say Files in the trail and keep the description in About only591 "name": name,
592 "path": data["path"],
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'593 "state": "active",
594 "html_url": format!("{SITE}/{}/{}/actions", repo.namespace, repo.name),
Files pages say Files in the trail and keep the description in About only595 },
596 "repository": payload::repository(repo),
597 "sender": payload::user(sender),
598 });
599 let mut subject = on_default(sha, payload, format!("After {name}"), None);
600 // Branch filters apply to the branch the followed run was on.
601 subject.filter_ref = format!("refs/heads/{head_branch}");
602 Some(subject)
603 }
604 "issues" | "issue_comment" => {
605 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9606 if action == Some("assigned") && nobody_added(data) {
607 return Ok(None);
608 }
Files pages say Files in the trail and keep the description in About only609 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
610 let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
611 let (issue_json, comments, title, on_pull) = match issue {
612 Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false),
613 Outcome::Fail(_) => {
614 let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
615 let Outcome::Ok(detail) = pull else { return Ok(None) };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar616 (payload::pull_as_issue(repo, &detail.pull), detail.comments, detail.pull.title.clone(), true)
Files pages say Files in the trail and keep the description in About only617 }
618 };
619 let mut payload = json!({
620 "action": action,
621 "issue": issue_json,
622 "repository": payload::repository(repo),
623 "sender": payload::user(sender),
624 });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'625 payload::changed(repo, &mut payload, data);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.626 self.label_colors(repo, ws, &mut payload).await?;
Files pages say Files in the trail and keep the description in About only627 if event_name == "issue_comment" {
628 let comment_id = data["commentId"].as_str();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts629 if action == Some("deleted") {
630 // Gone by now: as the event kept it.
631 match data.get("comment").filter(|kept| kept.is_object()) {
632 Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, on_pull),
633 None => return Ok(None),
634 }
635 } else {
636 let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id);
637 // A new comment is the newest; an edited one must be found.
638 let comment = if action == Some("created") { comment.or(comments.last()) } else { comment };
639 match comment {
640 Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
641 None => return Ok(None),
642 }
643 }
644 // `edited`: what the body was before.
645 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
646 payload["changes"] = changes.clone();
Files pages say Files in the trail and keep the description in About only647 }
648 }
649 Some(on_default(sha, payload, title, on_pull.then_some(number)))
650 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts651 // A release: on its tag, at the commit the tag named.
652 "release" => {
653 let release = &data["release"];
654 let Some(tag) = data["tagName"].as_str().or_else(|| release["tagName"].as_str()) else { return Ok(None) };
655 let sha = match release["target"].as_str().filter(|target| !target.is_empty()) {
656 Some(target) => target.to_owned(),
657 None => match self.default_head(repo).await? {
658 Some(head) => head,
659 None => return Ok(None),
660 },
661 };
662 let git_ref = format!("refs/tags/{tag}");
663 let mut payload = json!({
664 "action": action,
665 "release": payload::release(repo, release),
666 "repository": payload::repository(repo),
667 "sender": payload::user(sender),
668 });
669 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
670 payload["changes"] = changes.clone();
671 }
672 let name = release["name"].as_str().filter(|name| !name.is_empty()).unwrap_or(tag);
673 Some(Subject {
674 source: path.clone(),
675 source_ref: Some(sha.clone()),
676 git_ref: git_ref.clone(),
677 sha,
678 head_ref: None,
679 base_ref: None,
680 pull: None,
681 filter_ref: git_ref,
682 paths: None,
683 compare: None,
684 payload,
685 title: format!("Release {name} {}", action.unwrap_or("changed")),
686 trusted: true,
687 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'688 merge: None,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts689 })
690 }
691 // A deployment, or a new status of one: at the commit deployed,
692 // on the branch or tag it names (none for a bare commit).
693 "deployment" | "deployment_status" => {
694 let deployment = &data["deployment"];
695 let Some(sha) = deployment["sha"].as_str().filter(|sha| !sha.is_empty()).map(str::to_owned) else { return Ok(None) };
696 let named = deployment["ref"].as_str().unwrap_or_default();
697 let git_ref = if named.is_empty() || named == sha || is_commit(named) {
698 String::new()
699 } else if named.starts_with("refs/") {
700 named.to_owned()
701 } else if self.is_branch(repo, ws, named).await? {
702 format!("refs/heads/{named}")
703 } else {
704 format!("refs/tags/{named}")
705 };
706 let environment = deployment["environment"].as_str().unwrap_or_default();
707 let mut payload = json!({
708 "action": "created",
709 "deployment": payload::deployment(repo, deployment),
710 "repository": payload::repository(repo),
711 "sender": payload::user(sender),
712 });
713 let title = if event_name == "deployment_status" {
714 let status = &data["deploymentStatus"];
715 payload["deployment_status"] = payload::deployment_status(repo, status, deployment);
716 format!("Deployment to {environment}: {}", status["state"].as_str().unwrap_or("changed"))
717 } else {
718 format!("Deployment to {environment}")
719 };
720 Some(Subject {
721 source: path.clone(),
722 source_ref: Some(sha.clone()),
723 filter_ref: git_ref.clone(),
724 git_ref,
725 sha,
726 head_ref: None,
727 base_ref: None,
728 pull: None,
729 paths: None,
730 compare: None,
731 payload,
732 title,
733 trusted: true,
734 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'735 merge: None,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts736 })
737 }
Files pages say Files in the trail and keep the description in About only738 _ => None,
739 })
740 }
741
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails742 /// A push keeps the repository's schedules going (`run_schedules`).
743 /// Written at most once a day, and only on scheduled workflows.
744 async fn note_push(&self, repo_id: &str) -> Result<()> {
745 let at = now_ms();
746 self.db
747 .prepare("UPDATE workflows SET pushed_at = ? WHERE repo_id = ? AND crons != '[]' AND (pushed_at IS NULL OR pushed_at < ?)")
748 .bind(&[rfc3339(at).into(), repo_id.into(), rfc3339(at.saturating_sub(24 * 60 * 60 * 1000)).into()])?
749 .run()
750 .await?;
751 Ok(())
752 }
753
Files pages say Files in the trail and keep the description in About only754 pub async fn on_event(&self, event: &Event) -> Result<()> {
755 let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'756 let mut mapped = github_events(&event.kind);
757 // A new branch or tag is also `create`.
758 if event.kind == "git.push" && event.data["before"].is_null() {
759 mapped.push(("create", None));
760 }
Files pages say Files in the trail and keep the description in About only761 let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true);
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails762 if event.kind == "git.push" {
763 self.note_push(repo_id).await?;
764 }
Files pages say Files in the trail and keep the description in About only765 if mapped.is_empty() && !pushed_default {
766 return Ok(());
767 }
768 let Some((repo, ws)) = self.repo_by_id(repo_id).await? else { return Ok(()) };
769 if pushed_default {
770 self.sync(&repo, &ws).await?;
771 }
Merge branch 'mirroring' into artifacts-mode772 // A mirror runs only what its state says (mirrored.rs).
773 let copied_in = event.kind == "git.push" && event.data["mirrored"].as_bool() == Some(true);
774 let policy = crate::mirrored::policy(repo.mirror.as_ref(), copied_in);
775 if !policy.runs {
776 return Ok(());
777 }
Merge branch 'worktree-agent-a3abfcce648e87dca'778 // What a workflow job's own token did starts no workflows, as on
779 // GitHub, so a workflow cannot set itself off; only
780 // `workflow_dispatch` and `repository_dispatch` do.
781 if let Some(run) = caused_by_job(&event.data) {
782 worker::console_log!("actions: {} {} came from run {run}'s token; no workflows start for it", event.kind, event.id);
783 return Ok(());
784 }
Files pages say Files in the trail and keep the description in About only785 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
786 for (event_name, action) in mapped {
787 // Issues and comments start the default branch's workflows,
788 // which the synced table lists: when none listens, nothing is
789 // read from git. Agents make many of these events.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts790 // Deployments' statuses are as frequent (every g1t.page build
791 // reports several), so they look there first too.
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9792 if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete")
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts793 && self.listens(repo_id, event_name).await? == Some(false)
794 {
Files pages say Files in the trail and keep the description in About only795 continue;
796 }
797 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
798 continue;
799 };
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.800 // `[skip ci]` in the head commit's message, as on GitHub.
801 if matches!(event_name, "push" | "pull_request") {
802 let message = match subject.payload["head_commit"]["message"].as_str() {
803 Some(message) => Some(message.to_owned()),
804 None => self.head_message(&subject.source, &ws, &subject.sha).await?,
805 };
806 if message.as_deref().is_some_and(g1t_actions::events::skips_ci) {
807 worker::console_log!("actions: {} {} at {} asks to skip its runs", event.kind, event.id, subject.sha);
808 continue;
809 }
810 }
Merge branch 'mirroring' into artifacts-mode811 // A change to workflows made on the remote, by someone g1t
812 // knows nothing of, waits before it may use this repository's
813 // secrets.
814 if copied_in && matches!(event_name, "push" | "create") {
815 if subject.paths.is_none() {
816 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.817 subject.changed(self.changed_paths(&repo, &ws, base, head).await?);
Merge branch 'mirroring' into artifacts-mode818 }
819 if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default())
820 && let Some(mirror) = &repo.mirror
821 {
822 subject.approval = subject.approval.take().or_else(|| Some(crate::mirrored::copied_workflows(&mirror.remote)));
823 }
824 }
825 // A pull request from a fork reads the fork's files.
826 let read = if subject.source == Self::repo_path(&repo) {
827 self.read_for(&repo, &ws, subject.source_ref.as_deref(), policy.github).await?
828 } 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.829 // And this repository's managed workflows: they are its own,
830 // whatever the fork's files say (managed.rs).
831 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.832 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.833 read
Merge branch 'mirroring' into artifacts-mode834 };
Files pages say Files in the trail and keep the description in About only835 // A pull request's head runs each workflow once, however many
836 // events say it is there (marked ready, and pushed).
837 let key = match subject.pull {
838 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 timeouts839 // Reopened or made a draft again runs anew, at a head
840 // that may have run before.
841 let phase = match action {
842 Some("closed") => "closed".to_owned(),
843 Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id),
844 _ => "open".to_owned(),
845 };
Files pages say Files in the trail and keep the description in About only846 format!("{event_name}:{number}:{}:{phase}", subject.sha)
847 }
Merge branch 'worktree-agent-a3abfcce648e87dca'848 _ if event_name == "create" => format!("{}:create", event.id),
Files pages say Files in the trail and keep the description in About only849 _ => event.id.clone(),
850 };
851 self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender)
852 .await?;
853 }
854 Ok(())
855 }
856
857 #[allow(clippy::too_many_arguments)]
858 async fn start_matching(
859 &self,
860 repo: &Repo,
861 ws: &User,
862 read: Read,
863 subject: &mut Subject,
864 event_name: &str,
865 action: Option<&str>,
866 event_key: &str,
867 actor_id: Option<&str>,
868 sender: &str,
869 ) -> 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.870 let parsed: Vec<_> = read.files.into_iter().map(|file| (workflow::parse(&file.source), file)).collect();
871 // A push some workflow filters by path is compared before any run
872 // starts, so every run's payload lists what its commit changed.
873 let filters_paths = |(parsed, _): &(std::result::Result<Workflow, _>, _)| {
874 parsed.as_ref().ok().and_then(|w: &Workflow| w.trigger(event_name)).is_some_and(|t| t.paths.is_set())
875 };
876 if event_name == "push" && subject.paths.is_none() && parsed.iter().any(filters_paths) {
877 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
878 subject.changed(self.changed_paths(repo, ws, base, head).await?);
879 }
880 for (parsed, file) in parsed {
Files pages say Files in the trail and keep the description in About only881 let workflow = match parsed {
882 Ok(workflow) => workflow,
883 Err(problem) => {
884 // A push shows a broken workflow as a failed run, as GitHub does.
885 if event_name == "push" && file.source.contains("on") {
886 self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
887 .await?;
888 }
889 continue;
890 }
891 };
892 let Some(trigger) = workflow.trigger(event_name) else { continue };
893 // workflow_run follows the workflows it names.
894 if event_name == "workflow_run" {
895 let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default();
896 if !trigger.workflows.iter().any(|name| name == followed) {
897 continue;
898 }
899 }
900 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
901 continue;
902 }
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.903 // A workspace's required workflow runs whatever its row says.
904 if !file.required && self.disabled(&repo.id, &file.path).await? {
Files pages say Files in the trail and keep the description in About only905 continue;
906 }
Merge branch 'mirroring' into artifacts-mode907 // On a mirror the remote may deploy too: a workflow that deploys
908 // waits for approval (mirrored.rs).
909 let held = crate::mirrored::policy(repo.mirror.as_ref(), false)
910 .hold
911 .zip(crate::mirrored::deploys_to(&workflow))
912 .map(|(remote, environment)| crate::mirrored::held(&remote, &environment));
Files pages say Files in the trail and keep the description in About only913 self.create_run(NewRun {
914 repo: repo.clone(),
915 path: file.path,
916 source: file.source,
917 info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id),
918 workflow,
919 action: action.map(str::to_owned),
920 pull: subject.pull,
921 title: subject.title.clone(),
922 inputs: Map::new(),
923 event_key: event_key.to_owned(),
924 actor_id: actor_id.map(str::to_owned),
925 actor: Some(sender.to_owned()),
926 trusted: subject.trusted,
Merge branch 'mirroring' into artifacts-mode927 approval: subject.approval.clone().or(held),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'928 head_sha: subject.merge.as_ref().map(|_| subject.sha.clone()),
Files pages say Files in the trail and keep the description in About only929 })
930 .await?;
931 }
932 Ok(())
933 }
934
935 /// Whether the branch, tag and path filters let the event through.
936 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-7nm8r9937 // GitHub takes no filters on these: a workflow with `branches` on
938 // `create` or `delete` still runs for every branch and tag.
939 if unfiltered(event_name) {
940 return Ok(true);
941 }
Files pages say Files in the trail and keep the description in About only942 let git_ref = subject.filter_ref.as_str();
943 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
944 // A tag push runs a workflow that filters tags, or filters nothing.
945 if trigger.tags.is_set() {
946 if !trigger.tags.allows(tag) {
947 return Ok(false);
948 }
949 } else if trigger.branches.is_set() {
950 return Ok(false);
951 }
952 // Paths are not checked for tags, as on GitHub.
953 return Ok(true);
954 }
955 let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref);
956 if trigger.branches.is_set() {
957 if !trigger.branches.allows(branch) {
958 return Ok(false);
959 }
960 } else if event_name == "push" && trigger.tags.is_set() {
961 return Ok(false);
962 }
963 if trigger.paths.is_set() {
964 if subject.paths.is_none() {
965 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.966 subject.changed(self.changed_paths(repo, ws, base, head).await?);
Files pages say Files in the trail and keep the description in About only967 }
968 if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
969 return Ok(false);
970 }
971 }
972 Ok(true)
973 }
974
975 async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> {
976 let row = self
977 .db
978 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
979 .bind(&[repo_id.into(), path.into()])?
980 .first::<WorkflowRow>(None)
981 .await?;
982 Ok(row.is_some_and(|row| row.state == "disabled"))
983 }
984
985 fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo {
986 RunInfo {
987 repository: format!("{}/{}", repo.namespace, repo.name),
988 repository_id: repo.id.clone(),
989 default_branch: repo.default_branch.clone(),
990 event_name: event_name.to_owned(),
991 event: subject.payload.clone(),
992 git_ref: subject.git_ref.clone(),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'993 sha: subject.merge.clone().unwrap_or_else(|| subject.sha.clone()),
Files pages say Files in the trail and keep the description in About only994 head_ref: subject.head_ref.clone(),
995 base_ref: subject.base_ref.clone(),
996 actor: sender.to_owned(),
997 actor_id: actor_id.unwrap_or_default().to_owned(),
998 triggering_actor: sender.to_owned(),
999 run_id: String::new(),
1000 run_number: 0,
1001 run_attempt: 1,
1002 workflow: workflow.name.clone().unwrap_or_default(),
1003 workflow_path: String::new(),
1004 server_url: SITE.to_owned(),
1005 api_url: API.to_owned(),
1006 }
1007 }
1008
1009 #[allow(clippy::too_many_arguments)]
1010 async fn record_invalid(
1011 &self,
1012 repo: &Repo,
1013 path: &str,
1014 source: &str,
1015 subject: &Subject,
1016 event_key: &str,
1017 actor_id: Option<&str>,
1018 sender: &str,
1019 problem: &str,
1020 ) -> Result<()> {
1021 let row = self.workflow_row(repo, path, path, source).await?;
1022 self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
1023 }
1024
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1025 /// Scheduled workflows that run this minute, on the default branch: at
1026 /// most every five minutes (`cron::Schedule::runs_at`). Not in a
1027 /// repository with no push for [`crate::SCHEDULE_IDLE_MS`], nor for an
1028 /// hour after billing refused one of the workflow's scheduled jobs
1029 /// ([`crate::SCHEDULE_REFUSED_MS`]): no run is made only to be refused.
Files pages say Files in the trail and keep the description in About only1030 pub async fn run_schedules(&self, minute: u64) -> Result<()> {
1031 let rows = self
1032 .db
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1033 .prepare(
1034 "SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL
1035 AND COALESCE(pushed_at, updated_at) >= ? AND (schedule_refused_until IS NULL OR schedule_refused_until <= ?)",
1036 )
1037 .bind(&[rfc3339(minute.saturating_sub(crate::SCHEDULE_IDLE_MS)).into(), rfc3339(minute).into()])?
Files pages say Files in the trail and keep the description in About only1038 .all()
1039 .await?
1040 .results::<WorkflowRow>()?;
1041 for row in rows {
1042 let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1043 let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.runs_at(minute))) else {
Files pages say Files in the trail and keep the description in About only1044 continue;
1045 };
1046 let Ok(workflow) = workflow::parse(&row.source) else { continue };
Merge branch 'mirroring' into artifacts-mode1047 // Schedules wait while a repository is archived, or a mirror runs
1048 // nothing; a deleted one is not found.
1049 let Some((repo, _ws)) = self
1050 .repo_by_id(&row.repo_id)
1051 .await?
1052 .filter(|(repo, _)| !repo.archived() && crate::mirrored::policy(repo.mirror.as_ref(), false).runs)
1053 else {
1054 continue;
1055 };
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.1056 // A managed workflow's row may be out of date while its
1057 // repository waits to be synced again (managed.rs).
1058 if g1t_actions::managed::is_managed(&row.path) && !self.synced(&repo.id).await? {
1059 continue;
1060 }
Files pages say Files in the trail and keep the description in About only1061 let Some(sha) = self.default_head(&repo).await? else { continue };
1062 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
1063 let mut subject = Subject {
1064 source: Self::repo_path(&repo),
1065 source_ref: None,
1066 git_ref: format!("refs/heads/{}", repo.default_branch),
1067 sha,
1068 head_ref: None,
1069 base_ref: None,
1070 pull: None,
1071 filter_ref: String::new(),
1072 paths: None,
1073 compare: None,
1074 payload,
1075 title: format!("Scheduled: {cron}"),
1076 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1077 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1078 merge: None,
Files pages say Files in the trail and keep the description in About only1079 };
1080 subject.filter_ref = subject.git_ref.clone();
1081 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
1082 self.create_run(NewRun {
1083 repo: repo.clone(),
1084 path: row.path.clone(),
1085 source: row.source.clone(),
1086 workflow,
1087 info,
1088 action: None,
1089 pull: None,
1090 title: subject.title.clone(),
1091 inputs: Map::new(),
1092 event_key: format!("schedule:{minute}"),
1093 actor_id: None,
1094 actor: None,
1095 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1096 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1097 head_sha: None,
Files pages say Files in the trail and keep the description in About only1098 })
1099 .await?;
1100 }
1101 Ok(())
1102 }
1103
1104 /// `dispatch`: someone with the Write role runs a workflow that has
1105 /// `workflow_dispatch`.
1106 pub async fn dispatch(&self, a: DispatchArgs) -> Result<Outcome<WorkflowRun>> {
1107 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
1108 if repo.archived() {
1109 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1110 }
1111 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1112 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1113 };
1114 let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
1115 let full_ref = if git_ref.starts_with("refs/") {
1116 git_ref.clone()
1117 } else {
1118 // A branch if there is one by that name, otherwise a tag.
1119 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
1120 &self.repos,
1121 "branches",
1122 &g1t_contracts::repos::BranchesArgs {
1123 path: Self::repo_path(&repo),
1124 viewer: Some(ws.clone()),
1125 },
1126 )
1127 .await?;
1128 let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
1129 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
1130 };
1131 let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
Merge branch 'mirroring' into artifacts-mode1132 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
1133 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
1134 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1135 }
1136 let read = self.read_for(&repo, &ws, Some(&short), policy.github).await?;
Files pages say Files in the trail and keep the description in About only1137 let Some(sha) = read.head.clone() else {
1138 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
1139 };
1140 // A workflow is named by its file (`build.yml`), its path, or its id
1141 // (`wfl_…`), which stands for the path it was read from.
1142 let by_id = if a.workflow.starts_with("wfl_") {
1143 self.db
1144 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?")
1145 .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])?
1146 .first::<WorkflowRow>(None)
1147 .await?
1148 .map(|row| row.path)
1149 } else {
1150 None
1151 };
1152 let named = by_id.as_deref().unwrap_or(&a.workflow);
1153 let wanted = named.trim_start_matches(".g1t/workflows/");
1154 let Some(file) = read.files.iter().find(|file| {
1155 file.path.rsplit('/').next() == Some(wanted) || file.path == named
1156 }) else {
1157 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
1158 };
1159 let workflow = match workflow::parse(&file.source) {
1160 Ok(workflow) => workflow,
1161 Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
1162 };
1163 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
1164 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
1165 };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91166 // `type: environment` names one of the repository's environments.
1167 let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) {
1168 self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect()
1169 } else {
1170 Vec::new()
1171 };
1172 let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments));
Files pages say Files in the trail and keep the description in About only1173 let payload = json!({
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91174 // As GitHub gives them here: text, `true` and `false` included.
1175 // The `inputs` context keeps booleans and numbers.
1176 "inputs": event_inputs(&inputs),
Files pages say Files in the trail and keep the description in About only1177 "ref": full_ref,
1178 "repository": payload::repository(&repo),
1179 "sender": payload::user(&a.actor.username),
1180 "workflow": file.path,
1181 });
1182 let subject = Subject {
1183 source: Self::repo_path(&repo),
1184 source_ref: Some(sha.clone()),
1185 git_ref: full_ref.clone(),
1186 sha,
1187 head_ref: None,
1188 base_ref: None,
1189 pull: None,
1190 filter_ref: full_ref,
1191 paths: None,
1192 compare: None,
1193 payload,
1194 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
1195 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1196 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1197 merge: None,
Files pages say Files in the trail and keep the description in About only1198 };
1199 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1200 let created = self
1201 .create_run(NewRun {
1202 repo: repo.clone(),
1203 path: file.path.clone(),
1204 source: file.source.clone(),
1205 workflow,
1206 info,
1207 action: None,
1208 pull: None,
1209 title: subject.title.clone(),
1210 inputs,
1211 event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
1212 actor_id: Some(a.actor.id.clone()),
1213 actor: Some(a.actor.username.clone()),
1214 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1215 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1216 head_sha: None,
Files pages say Files in the trail and keep the description in About only1217 })
1218 .await?;
1219 match created {
1220 Some(id) => self.run_summary(&id).await,
1221 None => Ok(fail(FailureCode::Conflict, "It did not start.")),
1222 }
1223 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1224
1225 /// `repository_dispatch`: an outside event, by name, starts the default
1226 /// branch's workflows that run `on: repository_dispatch` with that type
1227 /// (or with no `types`). A workflow job's token may send one: this,
1228 /// with `workflow_dispatch`, is how a workflow starts another.
1229 pub async fn repository_dispatch(&self, a: RepositoryDispatchArgs) -> Result<Outcome<u32>> {
1230 let repo = check!(self.may(&a.actor, &a.repo, Capability::Push).await?);
1231 if repo.archived() {
1232 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1233 }
1234 let event_type = a.event_type.trim().to_owned();
1235 if event_type.is_empty() || event_type.chars().count() > 100 {
1236 return Ok(fail(FailureCode::Invalid, "event_type is 1 to 100 characters."));
1237 }
1238 let client_payload = match a.client_payload {
1239 Value::Null => json!({}),
1240 Value::Object(map) if map.len() <= 10 => Value::Object(map),
1241 Value::Object(_) => return Ok(fail(FailureCode::Invalid, "client_payload has at most 10 top-level properties.")),
1242 _ => return Ok(fail(FailureCode::Invalid, "client_payload is a JSON object.")),
1243 };
1244 if serde_json::to_string(&client_payload).map_or(0, |text| text.len()) > 64 * 1024 {
1245 return Ok(fail(FailureCode::Invalid, "client_payload is at most 64 KB."));
1246 }
1247 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1248 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1249 };
Merge branch 'mirroring' into artifacts-mode1250 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
1251 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
1252 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1253 }
1254 let read = self.read_for(&repo, &ws, Some(&repo.default_branch), policy.github).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1255 let Some(sha) = read.head.clone() else {
1256 return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet."));
1257 };
1258 let git_ref = format!("refs/heads/{}", repo.default_branch);
1259 let payload = json!({
1260 "action": event_type,
1261 "branch": repo.default_branch,
1262 "client_payload": client_payload,
1263 "repository": payload::repository(&repo),
1264 "sender": payload::user(&a.actor.username),
1265 });
1266 let key = format!("repository_dispatch:{}", new_id("dsp", now_ms()));
1267 let mut started = 0u32;
1268 for file in read.files {
1269 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1270 let Some(trigger) = workflow.trigger("repository_dispatch") else { continue };
1271 if !trigger.wants_type(Some(&event_type)) || self.disabled(&repo.id, &file.path).await? {
1272 continue;
1273 }
1274 let subject = Subject {
1275 source: Self::repo_path(&repo),
1276 source_ref: Some(sha.clone()),
1277 git_ref: git_ref.clone(),
1278 sha: sha.clone(),
1279 head_ref: None,
1280 base_ref: None,
1281 pull: None,
1282 filter_ref: git_ref.clone(),
1283 paths: None,
1284 compare: None,
1285 payload: payload.clone(),
1286 title: event_type.clone(),
1287 trusted: true,
1288 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1289 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1290 };
1291 let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1292 let created = self
1293 .create_run(NewRun {
1294 repo: repo.clone(),
1295 path: file.path.clone(),
1296 source: file.source.clone(),
1297 workflow,
1298 info,
1299 action: Some(event_type.clone()),
1300 pull: None,
1301 title: subject.title.clone(),
1302 inputs: Map::new(),
1303 event_key: key.clone(),
1304 actor_id: Some(a.actor.id.clone()),
1305 actor: Some(a.actor.username.clone()),
1306 trusted: true,
1307 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1308 head_sha: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1309 })
1310 .await?;
1311 if created.is_some() {
1312 started += 1;
1313 }
1314 }
1315 Ok(Outcome::Ok(started))
1316 }
Files pages say Files in the trail and keep the description in About only1317}
1318
1319#[derive(serde::Deserialize)]
1320#[serde(rename_all = "camelCase")]
1321pub struct MergeGroupArgs {
1322 pub repo_id: String,
1323 pub entry: String,
1324 pub sha: String,
1325 pub head_ref: String,
1326 #[serde(default)]
1327 pub base_sha: Option<String>,
1328 pub number: u32,
1329 #[serde(default)]
1330 pub ahead: Vec<u32>,
1331}
1332
1333impl Actions {
1334 /// `merge_group`: the merge queue built a state and its checks passed.
1335 /// Starts the workflows that run `on: merge_group` on it, as GitHub's
1336 /// queue does, and says how many started; the queue waits for their
1337 /// statuses on that commit.
1338 pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
1339 let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
1340 return Ok(Outcome::Ok(json!({ "runs": 0 })));
1341 };
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.1342 let mut read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
1343 // A required workflow's check holds the queue too (managed.rs).
1344 read.files = self.with_managed(&repo, read.files).await?;
Files pages say Files in the trail and keep the description in About only1345 let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
1346 let payload = json!({
1347 "action": "checks_requested",
1348 "merge_group": {
1349 "head_sha": a.sha,
1350 "head_ref": a.head_ref,
1351 "base_sha": a.base_sha,
1352 "base_ref": format!("refs/heads/{}", repo.default_branch),
1353 "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
1354 },
1355 "repository": payload::repository(&repo),
1356 "sender": payload::user(&repo.namespace),
1357 });
1358 let mut started = 0u32;
1359 for file in read.files {
1360 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1361 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.1362 if !trigger.wants_type(Some("checks_requested")) || (!file.required && self.disabled(&repo.id, &file.path).await?) {
Files pages say Files in the trail and keep the description in About only1363 continue;
1364 }
1365 // Branch filters on merge_group name the branch it merges into.
1366 if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
1367 continue;
1368 }
1369 let ahead = if a.ahead.is_empty() {
1370 String::new()
1371 } else {
1372 format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
1373 };
1374 let subject = Subject {
1375 source: Self::repo_path(&repo),
1376 source_ref: Some(a.sha.clone()),
1377 git_ref: a.head_ref.clone(),
1378 sha: a.sha.clone(),
1379 head_ref: None,
1380 base_ref: Some(repo.default_branch.clone()),
1381 pull: Some(a.number),
1382 filter_ref: format!("refs/heads/{}", repo.default_branch),
1383 paths: None,
1384 compare: None,
1385 payload: payload.clone(),
1386 title: format!("Merge queue: #{}{ahead}", a.number),
1387 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1388 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1389 merge: None,
Files pages say Files in the trail and keep the description in About only1390 };
1391 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
1392 let created = self
1393 .create_run(NewRun {
1394 repo: repo.clone(),
1395 path: file.path.clone(),
1396 source: file.source.clone(),
1397 workflow,
1398 info,
1399 action: Some("checks_requested".to_owned()),
1400 pull: Some(a.number),
1401 title: subject.title.clone(),
1402 inputs: Map::new(),
1403 event_key: format!("merge_group:{}:{}", a.entry, a.sha),
1404 actor_id: None,
1405 actor: None,
1406 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1407 approval: None,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1408 head_sha: None,
Files pages say Files in the trail and keep the description in About only1409 })
1410 .await?;
1411 if created.is_some() {
1412 started += 1;
1413 }
1414 }
1415 Ok(Outcome::Ok(json!({ "runs": started })))
1416 }
1417}
1418
1419/// The inputs of a manual run: what was given, checked against the
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91420/// workflow's declared inputs, with their defaults filled in. An input the
1421/// workflow does not declare is refused, as GitHub refuses it.
1422/// `environments` are the repository's, for `type: environment`.
1423fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> {
1424 let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect();
1425 if !unexpected.is_empty() {
1426 let names = serde_json::to_string(&unexpected).unwrap_or_default();
1427 let declared = if trigger.inputs.is_empty() {
1428 "The workflow declares no inputs.".to_owned()
1429 } else {
1430 format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", "))
1431 };
1432 return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}"));
1433 }
Files pages say Files in the trail and keep the description in About only1434 let mut inputs = Map::new();
1435 for (name, spec) in &trigger.inputs {
1436 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
1437 let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
1438 let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
1439 let value = match value {
1440 Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
1441 Some(Value::Null) | None => match kind {
1442 "boolean" => Value::Bool(false),
1443 _ => Value::String(String::new()),
1444 },
1445 Some(value) => match kind {
1446 "boolean" => Value::Bool(match &value {
1447 Value::Bool(flag) => *flag,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91448 Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true,
1449 Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false,
1450 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")),
Files pages say Files in the trail and keep the description in About only1451 }),
1452 "number" => match &value {
1453 Value::Number(_) => value,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91454 Value::String(text) => match number(text) {
Files pages say Files in the trail and keep the description in About only1455 Some(number) => Value::Number(number),
1456 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1457 },
1458 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1459 },
1460 "choice" => {
1461 let text = g1t_actions::expr::to_text(&value);
1462 let options: Vec<String> =
1463 spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
1464 if !options.is_empty() && !options.contains(&text) {
1465 return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
1466 }
1467 Value::String(text)
1468 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91469 "environment" => {
1470 let text = g1t_actions::expr::to_text(&value);
1471 let wanted = text.trim().to_ascii_lowercase();
1472 if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) {
1473 return fail(
1474 FailureCode::Invalid,
1475 format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()),
1476 );
1477 }
1478 Value::String(text)
1479 }
Files pages say Files in the trail and keep the description in About only1480 _ => Value::String(g1t_actions::expr::to_text(&value)),
1481 },
1482 };
1483 inputs.insert(name.clone(), value);
1484 }
1485 Outcome::Ok(inputs)
1486}
1487
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91488/// A number input given as text: a whole number stays one.
1489fn number(text: &str) -> Option<serde_json::Number> {
1490 let text = text.trim();
1491 match text.parse::<i64>() {
1492 Ok(whole) => Some(whole.into()),
1493 Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64),
1494 }
1495}
1496
1497/// A manual run's inputs as `github.event.inputs` gives them: every one as
1498/// text, as GitHub's `workflow_dispatch` payload has them.
1499fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> {
1500 inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect()
1501}
1502
Files pages say Files in the trail and keep the description in About only1503#[cfg(test)]
1504mod tests {
1505 use super::*;
1506 use g1t_contracts::work::{Pull, g1t_author};
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1507
1508 #[test]
1509 fn every_event_a_workflow_runs_on_is_sent_to_this_queue() {
1510 for kind in g1t_contracts::webhooks::EVENT_TYPES {
1511 if !github_events(kind).is_empty() {
1512 assert!(
1513 g1t_contracts::subscribers::routed("SUBSCRIBER_ACTIONS", kind),
1514 "{kind} starts workflows but is not routed to actions (g1t_contracts::subscribers)"
1515 );
1516 }
1517 }
1518 }
Files pages say Files in the trail and keep the description in About only1519 use g1t_contracts::{Membership, PrincipalKind};
1520
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1521 #[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.1522 fn a_pushers_email_is_their_noreply_address() {
1523 assert_eq!(pusher_email(Some("usr_01j9zq4m8x7k2v5n3b6c1d0efg"), "Ada").as_deref(), Some("6c1d0efg+ada@users.noreply.g1t.sh"));
1524 assert_eq!(pusher_email(Some(AGENT_ID), AGENT_NAME).as_deref(), Some(g1t_contracts::system::EMAIL));
1525 assert_eq!(pusher_email(Some("wsp_1"), "acme"), None);
1526 assert_eq!(pusher_email(None, "acme"), None);
1527 }
1528
1529 #[test]
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1530 fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() {
1531 let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
1532 let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
1533 let unavailable = PullMergeRef::Unavailable { reason: "too large".into() };
1534 assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into()));
1535 assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into()));
1536 // GitHub runs no pull_request workflow while it conflicts.
1537 for action in ["opened", "synchronize", "reopened", "edited"] {
1538 assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing);
1539 }
1540 // Closing it, or reviewing it, still runs, on the head.
1541 assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head);
1542 assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head);
1543 // What g1t could not merge itself runs on the head.
1544 assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head);
1545 }
1546
Files pages say Files in the trail and keep the description in About only1547 fn repo() -> Repo {
1548 serde_json::from_value(json!({
1549 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
1550 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
1551 }))
1552 .unwrap()
1553 }
1554
1555 fn person(id: &str, username: &str) -> User {
1556 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
1557 }
1558
1559 fn made_for(asker: User) -> Pull {
1560 serde_json::from_value(json!({
1561 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
1562 "agent": "g1t", "runtime": "hosted", "status": "open",
1563 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
1564 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
1565 "supersededBy": null, "checkStatus": null,
1566 "author": g1t_author(), "requestedBy": asker,
1567 "createdAt": "", "updatedAt": ""
1568 }))
1569 .unwrap()
1570 }
1571
1572 #[test]
1573 fn g1t_s_change_for_someone_is_trusted_as_they_are() {
1574 // Stored people carry no memberships, so identity is asked about
1575 // them; being g1t's change gives it nothing more.
1576 let pull = made_for(person("usr_2", "ana"));
1577 assert!(!trusted_outright(pull.owner(), &repo()));
1578 // Someone known to be able to push is trusted at once.
1579 let mut member = person("usr_1", "syntaqx");
1580 member.workspaces.push(Membership::member("acme"));
1581 let pull = made_for(member);
1582 assert!(trusted_outright(pull.owner(), &repo()));
1583 }
1584
1585 #[test]
1586 fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() {
1587 let pull = made_for(person("usr_1", "syntaqx"));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1588 let event = payload::pull(&repo(), &pull);
Files pages say Files in the trail and keep the description in About only1589 assert_eq!(event["user"]["login"], "g1t");
1590 assert_eq!(event["user"]["type"], "Bot");
1591 assert_eq!(event["requested_by"]["login"], "syntaqx");
1592 assert_eq!(event["requested_by"]["type"], "User");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1593 let as_issue = payload::pull_as_issue(&repo(), &pull);
Files pages say Files in the trail and keep the description in About only1594 assert_eq!(as_issue["user"]["login"], "g1t");
1595 assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
1596 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1597
1598 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1599 fn releases_deployments_and_deleted_comments_read_as_githubs() {
1600 let release = payload::release(
1601 &repo(),
1602 &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false,
1603 "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }),
1604 );
1605 assert_eq!(release["tag_name"], "v1.2.0");
1606 assert_eq!(release["name"], "v1.2.0");
1607 assert_eq!(release["prerelease"], true);
1608 assert_eq!(release["author"]["login"], "ana");
1609 assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0");
1610 let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana",
1611 "production_environment": false, "created_at": "t", "updated_at": "t" });
1612 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);
1613 assert_eq!(status["state"], "success");
1614 assert_eq!(status["environment"], "staging");
1615 assert_eq!(status["environment_url"], "https://s.example");
1616 assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1617 let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1618 assert_eq!(gone["user"]["login"], "bo");
1619 assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1620 assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
1621 assert!(!is_commit("main"));
1622 }
1623
1624 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1625 fn a_pull_request_names_its_own_base_labels_and_milestone() {
1626 let mut pull = made_for(person("usr_1", "syntaqx"));
1627 let event = payload::pull(&repo(), &pull);
1628 assert_eq!(event["base"]["ref"], repo().default_branch);
1629 pull.base = Some("release/1.x".into());
1630 pull.labels = vec!["bug".into()];
1631 pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() });
1632 let event = payload::pull(&repo(), &pull);
1633 assert_eq!(event["base"]["ref"], "release/1.x");
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1634 assert_eq!(event["labels"][0]["name"], "bug");
1635 assert!(event["labels"][0]["id"].is_u64());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1636 assert_eq!(event["milestone"]["title"], "1.1");
1637 let mut labeled = serde_json::json!({ "action": "labeled" });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1638 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 bar1639 assert_eq!(labeled["label"]["color"], "d73a4a");
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91640 let mut assigned = serde_json::json!({ "action": "assigned" });
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1641 payload::changed(&repo(), &mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91642 assert_eq!(assigned["assignee"]["login"], "bo");
1643 assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] })));
1644 assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
1645 }
1646
1647 fn dispatchable(yaml: &str) -> Trigger {
1648 workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone()
1649 }
1650
1651 fn given(value: Value) -> Map<String, Value> {
1652 value.as_object().unwrap().clone()
1653 }
1654
1655 const DISPATCH: &str = "on:
1656 workflow_dispatch:
1657 inputs:
1658 dry-run: { type: boolean, default: false }
1659 count: { type: number, default: 2 }
1660 target: { type: environment }
1661 level: { type: choice, options: [low, high], default: low }
1662 note: { required: false }
1663jobs:
1664 a:
1665 runs-on: x
1666 steps: [{ run: 'true' }]";
1667
1668 #[test]
1669 fn an_input_the_workflow_does_not_declare_is_refused() {
1670 let trigger = dispatchable(DISPATCH);
1671 let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else {
1672 panic!("an undeclared input was taken");
1673 };
1674 assert_eq!(failure.code, FailureCode::Invalid);
1675 assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message);
1676 assert!(failure.message.contains("`dry-run`"));
1677 let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]");
1678 let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() };
1679 assert!(failure.message.contains("declares no inputs"));
1680 assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty()));
1681 }
1682
1683 #[test]
1684 fn inputs_are_typed_and_the_event_has_them_as_text() {
1685 let trigger = dispatchable(DISPATCH);
1686 let environments = vec!["staging".to_owned(), "production".to_owned()];
1687 let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments)
1688 else {
1689 panic!("refused");
1690 };
1691 assert_eq!(inputs["dry-run"], json!(true));
1692 assert_eq!(inputs["count"], json!(3));
1693 assert_eq!(inputs["level"], json!("low"));
1694 assert_eq!(inputs["note"], json!(""));
1695 let event = event_inputs(&inputs);
1696 assert_eq!(event["dry-run"], json!("true"));
1697 assert_eq!(event["count"], json!("3"));
1698 assert_eq!(event["target"], json!("Staging"));
1699 let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() };
1700 assert_eq!(event_inputs(&defaults)["dry-run"], json!("false"));
1701 assert_eq!(defaults["count"], json!(2));
1702 let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) {
1703 Outcome::Fail(failure) => failure.message,
1704 Outcome::Ok(_) => panic!("taken"),
1705 };
1706 assert!(refused(json!({ "target": "qa" })).contains("none called `qa`"));
1707 assert!(refused(json!({ "dry-run": "yes" })).contains("true or false"));
1708 assert!(refused(json!({ "count": "many" })).contains("is a number"));
1709 assert!(refused(json!({ "level": "mid" })).contains("low, high"));
1710 }
1711
1712 #[test]
1713 fn create_and_delete_take_no_filters_and_name_their_ref() {
1714 assert!(unfiltered("create") && unfiltered("delete"));
1715 assert!(!unfiltered("push") && !unfiltered("pull_request"));
1716 assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x")));
1717 assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0")));
1718 assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None);
1719 assert_eq!(branch_or_tag("refs/notes/commits"), None);
1720 // A workflow can name `delete`. It takes no filters, as GitHub's
1721 // schema says, so writing one is refused when the workflow is read.
1722 assert!(workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").is_err());
1723 let workflow = workflow::parse("on:\n delete:\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1724 let trigger = workflow.trigger("delete").unwrap();
1725 assert!(trigger.wants_type(None));
1726 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 bar1727 }
Files pages say Files in the trail and keep the description in About only1728}

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