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

Prices are what g1t pays plus 20%, from the first second1//! What starts a run: an event on the bus, a schedule, or someone running a
2//! workflow by hand. Each finds the workflows that want it, at the commit
3//! the event is about, and checks their filters.
4
5use g1t_actions::events::{RunInfo, github_events};
6use g1t_actions::workflow::{self, Trigger, Workflow};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7use g1t_contracts::access::{self, Capability};
Merge branch 'worktree-agent-a3abfcce648e87dca'8use g1t_contracts::actions::{DispatchArgs, RepositoryDispatchArgs, WorkflowRun};
9use g1t_contracts::events::{Event, caused_by_job};
Prices are what g1t pays plus 20%, from the first second10use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.11use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath};
Prices are what g1t pays plus 20%, from the first second12use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs};
13use g1t_contracts::{FailureCode, Outcome, User, new_id};
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails14use g1t_contracts::time::rfc3339;
Prices are what g1t pays plus 20%, from the first second15use 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
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.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
Prices are what g1t pays plus 20%, from the first second27/// 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>,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.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>,
Prices are what g1t pays plus 20%, from the first second54}
55
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts56/// Whether a deployment's `ref` is a commit's full hash rather than a
57/// branch or tag.
58fn is_commit(name: &str) -> bool {
59 name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit())
60}
61
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r962/// What a ref made or deleted is, as `create` and `delete` name it:
63/// `branch` or `tag`, and its short name. `None` for any other ref, and
64/// for the merge queue's own branches, which come and go with every
65/// queued change.
66fn branch_or_tag(git_ref: &str) -> Option<(&'static str, &str)> {
67 if git_ref.starts_with("refs/heads/g1t-queue/") {
68 return None;
69 }
70 match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) {
71 (Some(branch), _) => Some(("branch", branch)),
72 (_, Some(tag)) => Some(("tag", tag)),
73 _ => None,
74 }
75}
76
77/// Events GitHub takes no `branches`, `tags` or `paths` filters on.
78fn unfiltered(event_name: &str) -> bool {
79 matches!(event_name, "create" | "delete")
80}
81
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.82/// What a pull request's run builds, given its test merge commit.
83#[derive(Debug, PartialEq, Eq)]
84enum PullBuild {
85 /// The merge commit, as on GitHub.
86 Merge(String),
87 /// The head: g1t could not make the merge commit itself, or a
88 /// conflicting pull request's event that still runs without one.
89 Head,
90 /// No run: GitHub starts no `pull_request` workflow while the pull
91 /// request has conflicts, since there is no merge commit to build. The
92 /// pull request's checks say so (apps/web `pull.tsx`).
93 Nothing,
94}
95
96fn pull_build(event_name: &str, action: Option<&str>, made: &PullMergeRef) -> PullBuild {
97 match made {
98 PullMergeRef::Merged { merge, .. } => PullBuild::Merge(merge.clone()),
99 PullMergeRef::Conflict { .. } if event_name == "pull_request" && action != Some("closed") => PullBuild::Nothing,
100 PullMergeRef::Conflict { .. } | PullMergeRef::Unavailable { .. } => PullBuild::Head,
101 }
102}
103
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9104/// Whether an `*.assigned` event added nobody: g1t says so for a change
105/// that only took people off.
106fn nobody_added(data: &Value) -> bool {
107 data["added"].as_array().is_some_and(Vec::is_empty)
108}
109
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights110/// Whether whoever a pull request is for is trusted without asking
111/// identity: g1t's agent in work nobody asked it for, or someone whose
112/// role here is known to allow pushing.
113fn trusted_outright(owner: &User, repo: &Repo) -> bool {
114 owner.id == AGENT_ID || access::can(Some(owner), repo, Capability::Push)
115}
116
Prices are what g1t pays plus 20%, from the first second117impl Actions {
118 async fn username(&self, id: Option<&str>) -> Result<Option<String>> {
119 let Some(id) = id else { return Ok(None) };
120 if id == AGENT_ID {
121 return Ok(Some(AGENT_NAME.to_owned()));
122 }
123 let names: std::collections::HashMap<String, String> =
124 g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.to_owned()] }).await?;
125 Ok(names.get(id).cloned())
126 }
127
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights128 /// Whether whoever a pull request is for (Pull::owner: whoever asked
129 /// g1t for it, or its author) could push to the repository, so its
130 /// runs get the secrets and a token. Anyone else's, a reader's included
131 /// (who may open one on a private repository too), runs without them.
132 /// A change g1t made for someone is trusted as they are.
133 async fn insider(&self, owner: &User, repo: &Repo, ws: &User) -> Result<bool> {
134 if trusted_outright(owner, repo) {
Prices are what g1t pays plus 20%, from the first second135 return Ok(true);
136 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look137 // Stored authors carry no memberships or grants: ask identity, as
138 // the workspace (which may see anyone's permission).
139 let permission: Outcome<access::PermissionInfo> = g1t_kit::call(
Prices are what g1t pays plus 20%, from the first second140 &self.identity,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look141 "collaborator_permission",
142 &access::CollaboratorPermissionArgs {
143 viewer: Some(ws.clone()),
144 path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights145 username: owner.username.clone(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look146 },
Prices are what g1t pays plus 20%, from the first second147 )
148 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look149 Ok(permission
150 .into_result()
151 .ok()
152 .and_then(|info| info.role)
153 .is_some_and(|role| access::allows(role, Capability::Push)))
Prices are what g1t pays plus 20%, from the first second154 }
155
156 async fn commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<Vec<Commit>> {
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.157 Ok(self.pushed_commits(repo, actor, after, before).await?.0)
158 }
159
160 /// The commits a push added, oldest first, and whether it was forced:
161 /// `before` is not in `after`'s history. Known only when that history
162 /// is read whole (it is shorter than the [`PUSH_LOG`] commits read);
163 /// a longer one says not forced.
164 async fn pushed_commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<(Vec<Commit>, bool)> {
Prices are what g1t pays plus 20%, from the first second165 let log: Outcome<Vec<Commit>> = g1t_kit::call(
166 &self.repos,
167 "log",
168 &LogArgs {
169 path: RepoPath {
170 namespace: repo.namespace.clone(),
171 name: repo.name.clone(),
172 },
173 viewer: Some(actor.clone()),
174 git_ref: Some(after.to_owned()),
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.175 limit: PUSH_LOG,
Prices are what g1t pays plus 20%, from the first second176 },
177 )
178 .await?;
179 let mut commits: Vec<Commit> = log.into_result().unwrap_or_default();
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.180 let whole = commits.len() < PUSH_LOG as usize;
181 let mut forced = false;
182 if let Some(before) = before.filter(|before| *before != payload::ZERO) {
183 match commits.iter().position(|commit| commit.hash == before) {
184 Some(at) => commits.truncate(at),
185 None => forced = whole && !commits.is_empty(),
186 }
Prices are what g1t pays plus 20%, from the first second187 }
188 // GitHub lists them oldest first, with the head commit last.
189 commits.reverse();
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.190 Ok((commits, forced))
Prices are what g1t pays plus 20%, from the first second191 }
192
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.193 /// The message of the commit `sha` in `path`, when it can be read.
194 async fn head_message(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<String>> {
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.195 Ok(self.commit_at(path, actor, sha).await?.map(|commit| commit.message))
196 }
197
198 /// The commit `sha` in `path`, when it can be read.
199 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.200 let log: Outcome<Vec<Commit>> = g1t_kit::call(
201 &self.repos,
202 "log",
203 &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 },
204 )
205 .await?;
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.206 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.207 }
208
Prices are what g1t pays plus 20%, from the first second209 async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> {
210 let compared: Outcome<Comparison> = g1t_kit::call(
211 &self.repos,
212 "compare",
213 &CompareArgs {
214 repo_id: repo.id.clone(),
215 viewer: Some(actor.clone()),
216 base,
217 head: Some(head),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar218 base_branch: None,
Prices are what g1t pays plus 20%, from the first second219 },
220 )
221 .await?;
222 Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| f.path).collect()).unwrap_or_default())
223 }
224
225 async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.226 self.branch_head(repo, &repo.default_branch).await
227 }
228
229 /// The commit `branch` points to now.
230 async fn branch_head(&self, repo: &Repo, branch: &str) -> Result<Option<String>> {
Prices are what g1t pays plus 20%, from the first second231 g1t_kit::call(
232 &self.repos,
233 "head",
234 &g1t_contracts::repos::HeadArgs {
235 repo_id: repo.id.clone(),
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.236 branch: branch.to_owned(),
Prices are what g1t pays plus 20%, from the first second237 },
238 )
239 .await
240 }
241
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.242 /// The pull request's test merge commit for `head` (repos
243 /// `pull_merge_ref`), made or moved now. A failure to ask is
244 /// unavailable, so that its runs still start, on the head.
245 async fn test_merge(&self, repo: &Repo, pull: &g1t_contracts::work::Pull, head: &str, base_ref: &str) -> PullMergeRef {
246 let asked: Result<PullMergeRef> = g1t_kit::call(
247 &self.repos,
248 "pull_merge_ref",
249 &PullMergeRefArgs {
250 repo_id: repo.id.clone(),
251 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
252 head: head.to_owned(),
253 number: pull.number,
254 target_branch: Some(base_ref.to_owned()),
255 },
256 )
257 .await;
258 asked.unwrap_or_else(|error| PullMergeRef::Unavailable { reason: error.to_string() })
259 }
260
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts261 /// Whether `name` is one of the repository's branches.
262 async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> {
263 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
264 &self.repos,
265 "branches",
266 &g1t_contracts::repos::BranchesArgs {
267 path: Self::repo_path(repo),
268 viewer: Some(ws.clone()),
269 },
270 )
271 .await?;
272 Ok(branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == name))
273 }
274
Prices are what g1t pays plus 20%, from the first second275 fn repo_path(repo: &Repo) -> RepoPath {
276 RepoPath {
277 namespace: repo.namespace.clone(),
278 name: repo.name.clone(),
279 }
280 }
281
282 /// The subject of an event of `kind`, as GitHub's `event_name`.
283 async fn subject(&self, event: &Event, event_name: &str, action: Option<&str>, repo: &Repo, ws: &User, sender: &str) -> Result<Option<Subject>> {
284 let path = Self::repo_path(repo);
285 let data = &event.data;
286 let on_default = |sha: String, payload: Value, title: String, pull: Option<u32>| Subject {
287 source: path.clone(),
288 source_ref: None,
289 git_ref: format!("refs/heads/{}", repo.default_branch),
290 sha,
291 head_ref: None,
292 base_ref: None,
293 pull,
294 filter_ref: format!("refs/heads/{}", repo.default_branch),
295 paths: None,
296 compare: None,
297 payload,
298 title,
299 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'300 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.301 merge: None,
Prices are what g1t pays plus 20%, from the first second302 };
303 let view = |number: u32| ViewArgs {
304 repo: path.clone(),
305 number,
306 viewer: Some(ws.clone()),
307 after_seq: 0,
308 };
309 Ok(match event_name {
310 "push" => {
311 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
312 return Ok(None);
313 };
314 // The merge queue's states run merge_group workflows, not push ones.
315 if git_ref.starts_with("refs/heads/g1t-queue/") {
316 return Ok(None);
317 }
318 let before = data["before"].as_str();
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.319 let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?;
Prices are what g1t pays plus 20%, from the first second320 let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.321 let payload = payload::push(repo, git_ref, before, after, &commits, sender, forced);
Prices are what g1t pays plus 20%, from the first second322 Some(Subject {
323 source: path.clone(),
324 source_ref: Some(after.to_owned()),
325 git_ref: git_ref.to_owned(),
326 sha: after.to_owned(),
327 head_ref: None,
328 base_ref: None,
329 pull: None,
330 filter_ref: git_ref.to_owned(),
331 paths: None,
332 compare: Some((before.map(str::to_owned), after.to_owned())),
333 payload,
334 title,
335 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'336 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.337 merge: None,
Prices are what g1t pays plus 20%, from the first second338 })
339 }
Merge branch 'worktree-agent-a3abfcce648e87dca'340 // A branch or tag was made: its own commit, as on GitHub.
341 "create" => {
342 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
343 return Ok(None);
344 };
345 if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() {
346 return Ok(None);
347 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9348 let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
Merge branch 'worktree-agent-a3abfcce648e87dca'349 let payload = json!({
350 "ref": name,
351 "ref_type": ref_type,
352 "master_branch": repo.default_branch,
353 "description": repo.description,
354 "pusher_type": "user",
355 "repository": payload::repository(repo),
356 "sender": payload::user(sender),
357 });
358 Some(Subject {
359 source: path.clone(),
360 source_ref: Some(after.to_owned()),
361 git_ref: git_ref.to_owned(),
362 sha: after.to_owned(),
363 head_ref: None,
364 base_ref: None,
365 pull: None,
366 filter_ref: git_ref.to_owned(),
367 paths: None,
368 compare: None,
369 payload,
370 title: format!("Created {ref_type} {name}"),
371 trusted: true,
372 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.373 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'374 })
375 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9376 // A branch or tag was deleted: on the default branch's latest
377 // commit, as on GitHub, since what was deleted has no commit to
378 // run on now.
379 "delete" => {
380 let Some(git_ref) = data["ref"].as_str() else { return Ok(None) };
381 let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
382 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
383 let payload = json!({
384 "ref": name,
385 "ref_type": ref_type,
386 "pusher_type": "user",
387 "repository": payload::repository(repo),
388 "sender": payload::user(sender),
389 });
390 Some(on_default(sha, payload, format!("Deleted {ref_type} {name}"), None))
391 }
Prices are what g1t pays plus 20%, from the first second392 "pull_request" | "pull_request_target" | "pull_request_review" => {
393 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-7nm8r9394 // Assignees changed but nobody was added: someone was only
395 // taken off, which is not `assigned`.
396 if action == Some("assigned") && nobody_added(data) {
397 return Ok(None);
398 }
Prices are what g1t pays plus 20%, from the first second399 let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
400 let Outcome::Ok(detail) = detail else { return Ok(None) };
401 let pull = &detail.pull;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar402 let base_ref = pull.base_branch(&repo.default_branch).to_owned();
Prices are what g1t pays plus 20%, from the first second403 let mut payload = json!({
404 "action": action,
405 "number": pull.number,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar406 "pull_request": payload::pull(repo, pull),
Prices are what g1t pays plus 20%, from the first second407 "repository": payload::repository(repo),
408 "sender": payload::user(sender),
409 });
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.410 payload::changed(repo, &mut payload, data);
411 // `base.sha` is the base branch's tip now, as GitHub gives
412 // it, not where the two last agreed.
413 if let Some(tip) = self.branch_head(repo, &base_ref).await? {
414 payload["pull_request"]["base"]["sha"] = json!(tip);
415 }
416 // A merge's commit, once it has landed.
417 if pull.status == g1t_contracts::work::PullStatus::Merged
418 && let Some(commit) = data["commit"].as_str()
419 {
420 payload["pull_request"]["merge_commit_sha"] = json!(commit);
421 }
Prices are what g1t pays plus 20%, from the first second422 if event_name == "pull_request_review" {
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar423 let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory);
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.424 payload["review"] = payload::review(repo, pull, review);
Prices are what g1t pays plus 20%, from the first second425 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights426 let trusted = self.insider(pull.owner(), repo, ws).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'427 // A pull request from outside may wait for approval before
428 // its head's code runs. `pull_request_target` runs the
429 // base's code, and a merged one's run the commit it landed
430 // as, so neither waits.
431 let approval = if event_name != "pull_request_target" && action != Some("closed") {
432 self.approval_needed(repo, pull.owner(), ws).await?
433 } else {
434 None
435 };
Prices are what g1t pays plus 20%, from the first second436 let head_ref = payload::head_ref(pull);
437 if event_name == "pull_request_target" {
438 // In the base's context: its workflows, its head.
439 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
440 let mut subject = on_default(sha, payload, pull.title.clone(), Some(pull.number));
441 subject.head_ref = Some(head_ref);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar442 subject.base_ref = Some(base_ref.clone());
443 subject.filter_ref = format!("refs/heads/{base_ref}");
Prices are what g1t pays plus 20%, from the first second444 subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect());
445 return Ok(Some(subject));
446 }
447 // A merged pull request's run is on the commit it landed as,
448 // in the repository; otherwise on its head, where that is.
449 let landed = match (action, data["commit"].as_str()) {
450 (Some("closed"), Some(commit)) => Some(commit.to_owned()),
451 _ => None,
452 };
453 let sha = match (&landed, data["commit"].as_str(), &pull.head_commit) {
454 (Some(commit), _, _) => commit.clone(),
455 (None, Some(commit), _) => commit.to_owned(),
456 (None, None, Some(head)) => head.clone(),
457 (None, None, None) => return Ok(None),
458 };
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.459 // Otherwise it builds the head merged into the base's tip,
460 // as on GitHub: the test merge commit at refs/pull/<n>/merge,
461 // in the repository, whose workflows it reads.
462 let merge = match landed {
463 Some(_) => None,
464 None => {
465 let made = self.test_merge(repo, pull, &sha, &base_ref).await;
466 match pull_build(event_name, action, &made) {
467 PullBuild::Merge(merge) => Some(merge),
468 PullBuild::Nothing => {
469 worker::console_log!("actions: pull request #{} conflicts with {base_ref}: {made:?}; no pull_request workflows run", pull.number);
470 return Ok(None);
471 }
472 PullBuild::Head => {
473 worker::console_log!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number);
474 None
475 }
476 }
477 }
478 };
479 if let Some(merge) = &merge {
480 payload["pull_request"]["merge_commit_sha"] = json!(merge);
481 }
482 let (source, source_ref) = match (&landed, &merge) {
483 (Some(_), _) => (path.clone(), sha.clone()),
484 (None, Some(merge)) => (path.clone(), merge.clone()),
485 (None, None) => (pull.fork.clone().unwrap_or_else(|| path.clone()), sha.clone()),
Prices are what g1t pays plus 20%, from the first second486 };
487 Some(Subject {
488 source,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.489 source_ref: Some(source_ref),
490 merge,
Prices are what g1t pays plus 20%, from the first second491 git_ref: format!("refs/pull/{}/merge", pull.number),
492 sha,
493 head_ref: Some(head_ref),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar494 base_ref: Some(base_ref.clone()),
Prices are what g1t pays plus 20%, from the first second495 pull: Some(pull.number),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar496 // `branches` filters on pull requests name the base.
497 filter_ref: format!("refs/heads/{base_ref}"),
Prices are what g1t pays plus 20%, from the first second498 paths: Some(pull.files.iter().map(|f| f.path.clone()).collect()),
499 compare: None,
500 payload,
501 title: pull.title.clone(),
502 trusted,
Merge branch 'worktree-agent-a3abfcce648e87dca'503 approval,
Prices are what g1t pays plus 20%, from the first second504 })
505 }
506 "workflow_run" => {
507 // A run of a workflow_run workflow does not start another,
508 // so two such workflows cannot set each other off.
509 if data["event"].as_str() == Some("workflow_run") {
510 return Ok(None);
511 }
512 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
513 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
514 let name = data["workflow"].as_str().unwrap_or_default();
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.515 // The followed run's row, for what the event does not carry.
516 let row = match data["runId"].as_str() {
517 Some(id) => self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<crate::plan::RunRow>(None).await?,
518 None => None,
519 };
520 let head_commit = match data["sha"].as_str() {
521 Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head),
522 None => None,
523 };
524 let followed = payload::FollowedRun {
525 data,
526 workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()),
527 attempt: row.as_ref().map(|r| r.attempt),
528 created_at: row.as_ref().map(|r| r.created_at.as_str()),
529 started_at: row.as_ref().and_then(|r| r.started_at.as_deref()),
530 updated_at: row.as_ref().and_then(|r| r.finished_at.as_deref()),
531 actor: row.as_ref().and_then(|r| r.actor.as_deref()),
532 triggering_actor: row.as_ref().and_then(|r| r.triggering_actor.as_deref()),
533 title: row.as_ref().map(|r| r.title.as_str()),
534 head_commit: head_commit.as_ref(),
535 };
Prices are what g1t pays plus 20%, from the first second536 let payload = json!({
537 "action": "completed",
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.538 "workflow_run": payload::workflow_run(repo, &followed),
539 "workflow": {
540 "id": followed.workflow_id,
541 "node_id": followed.workflow_id,
Prices are what g1t pays plus 20%, from the first second542 "name": name,
543 "path": data["path"],
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.544 "state": "active",
545 "html_url": format!("{SITE}/{}/{}/actions", repo.namespace, repo.name),
Prices are what g1t pays plus 20%, from the first second546 },
547 "repository": payload::repository(repo),
548 "sender": payload::user(sender),
549 });
550 let mut subject = on_default(sha, payload, format!("After {name}"), None);
551 // Branch filters apply to the branch the followed run was on.
552 subject.filter_ref = format!("refs/heads/{head_branch}");
553 Some(subject)
554 }
555 "issues" | "issue_comment" => {
556 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-7nm8r9557 if action == Some("assigned") && nobody_added(data) {
558 return Ok(None);
559 }
Prices are what g1t pays plus 20%, from the first second560 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
561 let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
562 let (issue_json, comments, title, on_pull) = match issue {
563 Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false),
564 Outcome::Fail(_) => {
565 let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
566 let Outcome::Ok(detail) = pull else { return Ok(None) };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar567 (payload::pull_as_issue(repo, &detail.pull), detail.comments, detail.pull.title.clone(), true)
Prices are what g1t pays plus 20%, from the first second568 }
569 };
570 let mut payload = json!({
571 "action": action,
572 "issue": issue_json,
573 "repository": payload::repository(repo),
574 "sender": payload::user(sender),
575 });
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.576 payload::changed(repo, &mut payload, data);
Prices are what g1t pays plus 20%, from the first second577 if event_name == "issue_comment" {
578 let comment_id = data["commentId"].as_str();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts579 if action == Some("deleted") {
580 // Gone by now: as the event kept it.
581 match data.get("comment").filter(|kept| kept.is_object()) {
582 Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, on_pull),
583 None => return Ok(None),
584 }
585 } else {
586 let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id);
587 // A new comment is the newest; an edited one must be found.
588 let comment = if action == Some("created") { comment.or(comments.last()) } else { comment };
589 match comment {
590 Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
591 None => return Ok(None),
592 }
593 }
594 // `edited`: what the body was before.
595 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
596 payload["changes"] = changes.clone();
Prices are what g1t pays plus 20%, from the first second597 }
598 }
599 Some(on_default(sha, payload, title, on_pull.then_some(number)))
600 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts601 // A release: on its tag, at the commit the tag named.
602 "release" => {
603 let release = &data["release"];
604 let Some(tag) = data["tagName"].as_str().or_else(|| release["tagName"].as_str()) else { return Ok(None) };
605 let sha = match release["target"].as_str().filter(|target| !target.is_empty()) {
606 Some(target) => target.to_owned(),
607 None => match self.default_head(repo).await? {
608 Some(head) => head,
609 None => return Ok(None),
610 },
611 };
612 let git_ref = format!("refs/tags/{tag}");
613 let mut payload = json!({
614 "action": action,
615 "release": payload::release(repo, release),
616 "repository": payload::repository(repo),
617 "sender": payload::user(sender),
618 });
619 if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
620 payload["changes"] = changes.clone();
621 }
622 let name = release["name"].as_str().filter(|name| !name.is_empty()).unwrap_or(tag);
623 Some(Subject {
624 source: path.clone(),
625 source_ref: Some(sha.clone()),
626 git_ref: git_ref.clone(),
627 sha,
628 head_ref: None,
629 base_ref: None,
630 pull: None,
631 filter_ref: git_ref,
632 paths: None,
633 compare: None,
634 payload,
635 title: format!("Release {name} {}", action.unwrap_or("changed")),
636 trusted: true,
637 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.638 merge: None,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts639 })
640 }
641 // A deployment, or a new status of one: at the commit deployed,
642 // on the branch or tag it names (none for a bare commit).
643 "deployment" | "deployment_status" => {
644 let deployment = &data["deployment"];
645 let Some(sha) = deployment["sha"].as_str().filter(|sha| !sha.is_empty()).map(str::to_owned) else { return Ok(None) };
646 let named = deployment["ref"].as_str().unwrap_or_default();
647 let git_ref = if named.is_empty() || named == sha || is_commit(named) {
648 String::new()
649 } else if named.starts_with("refs/") {
650 named.to_owned()
651 } else if self.is_branch(repo, ws, named).await? {
652 format!("refs/heads/{named}")
653 } else {
654 format!("refs/tags/{named}")
655 };
656 let environment = deployment["environment"].as_str().unwrap_or_default();
657 let mut payload = json!({
658 "action": "created",
659 "deployment": payload::deployment(repo, deployment),
660 "repository": payload::repository(repo),
661 "sender": payload::user(sender),
662 });
663 let title = if event_name == "deployment_status" {
664 let status = &data["deploymentStatus"];
665 payload["deployment_status"] = payload::deployment_status(repo, status, deployment);
666 format!("Deployment to {environment}: {}", status["state"].as_str().unwrap_or("changed"))
667 } else {
668 format!("Deployment to {environment}")
669 };
670 Some(Subject {
671 source: path.clone(),
672 source_ref: Some(sha.clone()),
673 filter_ref: git_ref.clone(),
674 git_ref,
675 sha,
676 head_ref: None,
677 base_ref: None,
678 pull: None,
679 paths: None,
680 compare: None,
681 payload,
682 title,
683 trusted: true,
684 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.685 merge: None,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts686 })
687 }
Prices are what g1t pays plus 20%, from the first second688 _ => None,
689 })
690 }
691
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails692 /// A push keeps the repository's schedules going (`run_schedules`).
693 /// Written at most once a day, and only on scheduled workflows.
694 async fn note_push(&self, repo_id: &str) -> Result<()> {
695 let at = now_ms();
696 self.db
697 .prepare("UPDATE workflows SET pushed_at = ? WHERE repo_id = ? AND crons != '[]' AND (pushed_at IS NULL OR pushed_at < ?)")
698 .bind(&[rfc3339(at).into(), repo_id.into(), rfc3339(at.saturating_sub(24 * 60 * 60 * 1000)).into()])?
699 .run()
700 .await?;
701 Ok(())
702 }
703
Prices are what g1t pays plus 20%, from the first second704 pub async fn on_event(&self, event: &Event) -> Result<()> {
705 let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
Merge branch 'worktree-agent-a3abfcce648e87dca'706 let mut mapped = github_events(&event.kind);
707 // A new branch or tag is also `create`.
708 if event.kind == "git.push" && event.data["before"].is_null() {
709 mapped.push(("create", None));
710 }
Prices are what g1t pays plus 20%, from the first second711 let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true);
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails712 if event.kind == "git.push" {
713 self.note_push(repo_id).await?;
714 }
Prices are what g1t pays plus 20%, from the first second715 if mapped.is_empty() && !pushed_default {
716 return Ok(());
717 }
718 let Some((repo, ws)) = self.repo_by_id(repo_id).await? else { return Ok(()) };
719 if pushed_default {
720 self.sync(&repo, &ws).await?;
721 }
Merge branch 'mirroring' into artifacts-mode722 // A mirror runs only what its state says (mirrored.rs).
723 let copied_in = event.kind == "git.push" && event.data["mirrored"].as_bool() == Some(true);
724 let policy = crate::mirrored::policy(repo.mirror.as_ref(), copied_in);
725 if !policy.runs {
726 return Ok(());
727 }
Merge branch 'worktree-agent-a3abfcce648e87dca'728 // What a workflow job's own token did starts no workflows, as on
729 // GitHub, so a workflow cannot set itself off; only
730 // `workflow_dispatch` and `repository_dispatch` do.
731 if let Some(run) = caused_by_job(&event.data) {
732 worker::console_log!("actions: {} {} came from run {run}'s token; no workflows start for it", event.kind, event.id);
733 return Ok(());
734 }
Prices are what g1t pays plus 20%, from the first second735 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
736 for (event_name, action) in mapped {
Fewer Artifacts reads: the store is asked for a handle only when needed, objects are kept in the isolate, and issue events read no workflows nobody listens for737 // Issues and comments start the default branch's workflows,
738 // which the synced table lists: when none listens, nothing is
739 // read from git. Agents make many of these events.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts740 // Deployments' statuses are as frequent (every g1t.page build
741 // reports several), so they look there first too.
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9742 if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete")
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts743 && self.listens(repo_id, event_name).await? == Some(false)
744 {
Fewer Artifacts reads: the store is asked for a handle only when needed, objects are kept in the isolate, and issue events read no workflows nobody listens for745 continue;
746 }
Prices are what g1t pays plus 20%, from the first second747 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
748 continue;
749 };
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.750 // `[skip ci]` in the head commit's message, as on GitHub.
751 if matches!(event_name, "push" | "pull_request") {
752 let message = match subject.payload["head_commit"]["message"].as_str() {
753 Some(message) => Some(message.to_owned()),
754 None => self.head_message(&subject.source, &ws, &subject.sha).await?,
755 };
756 if message.as_deref().is_some_and(g1t_actions::events::skips_ci) {
757 worker::console_log!("actions: {} {} at {} asks to skip its runs", event.kind, event.id, subject.sha);
758 continue;
759 }
760 }
Merge branch 'mirroring' into artifacts-mode761 // A change to workflows made on the remote, by someone g1t
762 // knows nothing of, waits before it may use this repository's
763 // secrets.
764 if copied_in && matches!(event_name, "push" | "create") {
765 if subject.paths.is_none() {
766 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
767 subject.paths = Some(self.changed_paths(&repo, &ws, base, head).await?);
768 }
769 if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default())
770 && let Some(mirror) = &repo.mirror
771 {
772 subject.approval = subject.approval.take().or_else(|| Some(crate::mirrored::copied_workflows(&mirror.remote)));
773 }
774 }
775 // A pull request from a fork reads the fork's files.
776 let read = if subject.source == Self::repo_path(&repo) {
777 self.read_for(&repo, &ws, subject.source_ref.as_deref(), policy.github).await?
778 } 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.779 // And this repository's managed workflows: they are its own,
780 // whatever the fork's files say (managed.rs).
781 let mut read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?;
782 let managed = self.managed_beside(&repo.id, &read.files).await?;
783 read.files.extend(managed);
784 read
Merge branch 'mirroring' into artifacts-mode785 };
Prices are what g1t pays plus 20%, from the first second786 // A pull request's head runs each workflow once, however many
787 // events say it is there (marked ready, and pushed).
788 let key = match subject.pull {
789 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 timeouts790 // Reopened or made a draft again runs anew, at a head
791 // that may have run before.
792 let phase = match action {
793 Some("closed") => "closed".to_owned(),
794 Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id),
795 _ => "open".to_owned(),
796 };
Prices are what g1t pays plus 20%, from the first second797 format!("{event_name}:{number}:{}:{phase}", subject.sha)
798 }
Merge branch 'worktree-agent-a3abfcce648e87dca'799 _ if event_name == "create" => format!("{}:create", event.id),
Prices are what g1t pays plus 20%, from the first second800 _ => event.id.clone(),
801 };
802 self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender)
803 .await?;
804 }
805 Ok(())
806 }
807
808 #[allow(clippy::too_many_arguments)]
809 async fn start_matching(
810 &self,
811 repo: &Repo,
812 ws: &User,
813 read: Read,
814 subject: &mut Subject,
815 event_name: &str,
816 action: Option<&str>,
817 event_key: &str,
818 actor_id: Option<&str>,
819 sender: &str,
820 ) -> Result<()> {
821 for file in read.files {
822 let parsed = workflow::parse(&file.source);
823 let workflow = match parsed {
824 Ok(workflow) => workflow,
825 Err(problem) => {
826 // A push shows a broken workflow as a failed run, as GitHub does.
827 if event_name == "push" && file.source.contains("on") {
828 self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
829 .await?;
830 }
831 continue;
832 }
833 };
834 let Some(trigger) = workflow.trigger(event_name) else { continue };
835 // workflow_run follows the workflows it names.
836 if event_name == "workflow_run" {
837 let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default();
838 if !trigger.workflows.iter().any(|name| name == followed) {
839 continue;
840 }
841 }
842 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
843 continue;
844 }
845 if self.disabled(&repo.id, &file.path).await? {
846 continue;
847 }
Merge branch 'mirroring' into artifacts-mode848 // On a mirror the remote may deploy too: a workflow that deploys
849 // waits for approval (mirrored.rs).
850 let held = crate::mirrored::policy(repo.mirror.as_ref(), false)
851 .hold
852 .zip(crate::mirrored::deploys_to(&workflow))
853 .map(|(remote, environment)| crate::mirrored::held(&remote, &environment));
Prices are what g1t pays plus 20%, from the first second854 self.create_run(NewRun {
855 repo: repo.clone(),
856 path: file.path,
857 source: file.source,
858 info: self.run_info(repo, &workflow, event_name, subject, sender, actor_id),
859 workflow,
860 action: action.map(str::to_owned),
861 pull: subject.pull,
862 title: subject.title.clone(),
863 inputs: Map::new(),
864 event_key: event_key.to_owned(),
865 actor_id: actor_id.map(str::to_owned),
866 actor: Some(sender.to_owned()),
867 trusted: subject.trusted,
Merge branch 'mirroring' into artifacts-mode868 approval: subject.approval.clone().or(held),
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.869 head_sha: subject.merge.as_ref().map(|_| subject.sha.clone()),
Prices are what g1t pays plus 20%, from the first second870 })
871 .await?;
872 }
873 Ok(())
874 }
875
876 /// Whether the branch, tag and path filters let the event through.
877 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-7nm8r9878 // GitHub takes no filters on these: a workflow with `branches` on
879 // `create` or `delete` still runs for every branch and tag.
880 if unfiltered(event_name) {
881 return Ok(true);
882 }
Prices are what g1t pays plus 20%, from the first second883 let git_ref = subject.filter_ref.as_str();
884 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
885 // A tag push runs a workflow that filters tags, or filters nothing.
886 if trigger.tags.is_set() {
887 if !trigger.tags.allows(tag) {
888 return Ok(false);
889 }
890 } else if trigger.branches.is_set() {
891 return Ok(false);
892 }
893 // Paths are not checked for tags, as on GitHub.
894 return Ok(true);
895 }
896 let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref);
897 if trigger.branches.is_set() {
898 if !trigger.branches.allows(branch) {
899 return Ok(false);
900 }
901 } else if event_name == "push" && trigger.tags.is_set() {
902 return Ok(false);
903 }
904 if trigger.paths.is_set() {
905 if subject.paths.is_none() {
906 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
907 subject.paths = Some(self.changed_paths(repo, ws, base, head).await?);
908 }
909 if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
910 return Ok(false);
911 }
912 }
913 Ok(true)
914 }
915
916 async fn disabled(&self, repo_id: &str, path: &str) -> Result<bool> {
917 let row = self
918 .db
919 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND path = ?")
920 .bind(&[repo_id.into(), path.into()])?
921 .first::<WorkflowRow>(None)
922 .await?;
923 Ok(row.is_some_and(|row| row.state == "disabled"))
924 }
925
926 fn run_info(&self, repo: &Repo, workflow: &Workflow, event_name: &str, subject: &Subject, sender: &str, actor_id: Option<&str>) -> RunInfo {
927 RunInfo {
928 repository: format!("{}/{}", repo.namespace, repo.name),
929 repository_id: repo.id.clone(),
930 default_branch: repo.default_branch.clone(),
931 event_name: event_name.to_owned(),
932 event: subject.payload.clone(),
933 git_ref: subject.git_ref.clone(),
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.934 sha: subject.merge.clone().unwrap_or_else(|| subject.sha.clone()),
Prices are what g1t pays plus 20%, from the first second935 head_ref: subject.head_ref.clone(),
936 base_ref: subject.base_ref.clone(),
937 actor: sender.to_owned(),
938 actor_id: actor_id.unwrap_or_default().to_owned(),
939 triggering_actor: sender.to_owned(),
940 run_id: String::new(),
941 run_number: 0,
942 run_attempt: 1,
943 workflow: workflow.name.clone().unwrap_or_default(),
944 workflow_path: String::new(),
945 server_url: SITE.to_owned(),
946 api_url: API.to_owned(),
947 }
948 }
949
950 #[allow(clippy::too_many_arguments)]
951 async fn record_invalid(
952 &self,
953 repo: &Repo,
954 path: &str,
955 source: &str,
956 subject: &Subject,
957 event_key: &str,
958 actor_id: Option<&str>,
959 sender: &str,
960 problem: &str,
961 ) -> Result<()> {
962 let row = self.workflow_row(repo, path, path, source).await?;
963 self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
964 }
965
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails966 /// Scheduled workflows that run this minute, on the default branch: at
967 /// most every five minutes (`cron::Schedule::runs_at`). Not in a
968 /// repository with no push for [`crate::SCHEDULE_IDLE_MS`], nor for an
969 /// hour after billing refused one of the workflow's scheduled jobs
970 /// ([`crate::SCHEDULE_REFUSED_MS`]): no run is made only to be refused.
Prices are what g1t pays plus 20%, from the first second971 pub async fn run_schedules(&self, minute: u64) -> Result<()> {
972 let rows = self
973 .db
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails974 .prepare(
975 "SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL
976 AND COALESCE(pushed_at, updated_at) >= ? AND (schedule_refused_until IS NULL OR schedule_refused_until <= ?)",
977 )
978 .bind(&[rfc3339(minute.saturating_sub(crate::SCHEDULE_IDLE_MS)).into(), rfc3339(minute).into()])?
Prices are what g1t pays plus 20%, from the first second979 .all()
980 .await?
981 .results::<WorkflowRow>()?;
982 for row in rows {
983 let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails984 let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.runs_at(minute))) else {
Prices are what g1t pays plus 20%, from the first second985 continue;
986 };
987 let Ok(workflow) = workflow::parse(&row.source) else { continue };
Merge branch 'mirroring' into artifacts-mode988 // Schedules wait while a repository is archived, or a mirror runs
989 // nothing; a deleted one is not found.
990 let Some((repo, _ws)) = self
991 .repo_by_id(&row.repo_id)
992 .await?
993 .filter(|(repo, _)| !repo.archived() && crate::mirrored::policy(repo.mirror.as_ref(), false).runs)
994 else {
995 continue;
996 };
Prices are what g1t pays plus 20%, from the first second997 let Some(sha) = self.default_head(&repo).await? else { continue };
998 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
999 let mut subject = Subject {
1000 source: Self::repo_path(&repo),
1001 source_ref: None,
1002 git_ref: format!("refs/heads/{}", repo.default_branch),
1003 sha,
1004 head_ref: None,
1005 base_ref: None,
1006 pull: None,
1007 filter_ref: String::new(),
1008 paths: None,
1009 compare: None,
1010 payload,
1011 title: format!("Scheduled: {cron}"),
1012 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1013 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1014 merge: None,
Prices are what g1t pays plus 20%, from the first second1015 };
1016 subject.filter_ref = subject.git_ref.clone();
1017 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
1018 self.create_run(NewRun {
1019 repo: repo.clone(),
1020 path: row.path.clone(),
1021 source: row.source.clone(),
1022 workflow,
1023 info,
1024 action: None,
1025 pull: None,
1026 title: subject.title.clone(),
1027 inputs: Map::new(),
1028 event_key: format!("schedule:{minute}"),
1029 actor_id: None,
1030 actor: None,
1031 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1032 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1033 head_sha: None,
Prices are what g1t pays plus 20%, from the first second1034 })
1035 .await?;
1036 }
1037 Ok(())
1038 }
1039
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1040 /// `dispatch`: someone with the Write role runs a workflow that has
1041 /// `workflow_dispatch`.
Prices are what g1t pays plus 20%, from the first second1042 pub async fn dispatch(&self, a: DispatchArgs) -> Result<Outcome<WorkflowRun>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1043 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
1044 if repo.archived() {
1045 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
Prices are what g1t pays plus 20%, from the first second1046 }
1047 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1048 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1049 };
1050 let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
1051 let full_ref = if git_ref.starts_with("refs/") {
1052 git_ref.clone()
1053 } else {
1054 // A branch if there is one by that name, otherwise a tag.
1055 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
1056 &self.repos,
1057 "branches",
1058 &g1t_contracts::repos::BranchesArgs {
1059 path: Self::repo_path(&repo),
1060 viewer: Some(ws.clone()),
1061 },
1062 )
1063 .await?;
1064 let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
1065 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
1066 };
1067 let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
Merge branch 'mirroring' into artifacts-mode1068 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
1069 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
1070 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1071 }
1072 let read = self.read_for(&repo, &ws, Some(&short), policy.github).await?;
Prices are what g1t pays plus 20%, from the first second1073 let Some(sha) = read.head.clone() else {
1074 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
1075 };
1076 // A workflow is named by its file (`build.yml`), its path, or its id
1077 // (`wfl_…`), which stands for the path it was read from.
1078 let by_id = if a.workflow.starts_with("wfl_") {
1079 self.db
1080 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?")
1081 .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])?
1082 .first::<WorkflowRow>(None)
1083 .await?
1084 .map(|row| row.path)
1085 } else {
1086 None
1087 };
1088 let named = by_id.as_deref().unwrap_or(&a.workflow);
1089 let wanted = named.trim_start_matches(".g1t/workflows/");
1090 let Some(file) = read.files.iter().find(|file| {
1091 file.path.rsplit('/').next() == Some(wanted) || file.path == named
1092 }) else {
1093 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
1094 };
1095 let workflow = match workflow::parse(&file.source) {
1096 Ok(workflow) => workflow,
1097 Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
1098 };
1099 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
1100 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
1101 };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91102 // `type: environment` names one of the repository's environments.
1103 let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) {
1104 self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect()
1105 } else {
1106 Vec::new()
1107 };
1108 let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments));
Prices are what g1t pays plus 20%, from the first second1109 let payload = json!({
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91110 // As GitHub gives them here: text, `true` and `false` included.
1111 // The `inputs` context keeps booleans and numbers.
1112 "inputs": event_inputs(&inputs),
Prices are what g1t pays plus 20%, from the first second1113 "ref": full_ref,
1114 "repository": payload::repository(&repo),
1115 "sender": payload::user(&a.actor.username),
1116 "workflow": file.path,
1117 });
1118 let subject = Subject {
1119 source: Self::repo_path(&repo),
1120 source_ref: Some(sha.clone()),
1121 git_ref: full_ref.clone(),
1122 sha,
1123 head_ref: None,
1124 base_ref: None,
1125 pull: None,
1126 filter_ref: full_ref,
1127 paths: None,
1128 compare: None,
1129 payload,
1130 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
1131 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1132 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1133 merge: None,
Prices are what g1t pays plus 20%, from the first second1134 };
1135 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1136 let created = self
1137 .create_run(NewRun {
1138 repo: repo.clone(),
1139 path: file.path.clone(),
1140 source: file.source.clone(),
1141 workflow,
1142 info,
1143 action: None,
1144 pull: None,
1145 title: subject.title.clone(),
1146 inputs,
1147 event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
1148 actor_id: Some(a.actor.id.clone()),
1149 actor: Some(a.actor.username.clone()),
1150 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1151 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1152 head_sha: None,
Prices are what g1t pays plus 20%, from the first second1153 })
1154 .await?;
1155 match created {
1156 Some(id) => self.run_summary(&id).await,
1157 None => Ok(fail(FailureCode::Conflict, "It did not start.")),
1158 }
1159 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1160
1161 /// `repository_dispatch`: an outside event, by name, starts the default
1162 /// branch's workflows that run `on: repository_dispatch` with that type
1163 /// (or with no `types`). A workflow job's token may send one: this,
1164 /// with `workflow_dispatch`, is how a workflow starts another.
1165 pub async fn repository_dispatch(&self, a: RepositoryDispatchArgs) -> Result<Outcome<u32>> {
1166 let repo = check!(self.may(&a.actor, &a.repo, Capability::Push).await?);
1167 if repo.archived() {
1168 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1169 }
1170 let event_type = a.event_type.trim().to_owned();
1171 if event_type.is_empty() || event_type.chars().count() > 100 {
1172 return Ok(fail(FailureCode::Invalid, "event_type is 1 to 100 characters."));
1173 }
1174 let client_payload = match a.client_payload {
1175 Value::Null => json!({}),
1176 Value::Object(map) if map.len() <= 10 => Value::Object(map),
1177 Value::Object(_) => return Ok(fail(FailureCode::Invalid, "client_payload has at most 10 top-level properties.")),
1178 _ => return Ok(fail(FailureCode::Invalid, "client_payload is a JSON object.")),
1179 };
1180 if serde_json::to_string(&client_payload).map_or(0, |text| text.len()) > 64 * 1024 {
1181 return Ok(fail(FailureCode::Invalid, "client_payload is at most 64 KB."));
1182 }
1183 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1184 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1185 };
Merge branch 'mirroring' into artifacts-mode1186 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
1187 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
1188 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1189 }
1190 let read = self.read_for(&repo, &ws, Some(&repo.default_branch), policy.github).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1191 let Some(sha) = read.head.clone() else {
1192 return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet."));
1193 };
1194 let git_ref = format!("refs/heads/{}", repo.default_branch);
1195 let payload = json!({
1196 "action": event_type,
1197 "branch": repo.default_branch,
1198 "client_payload": client_payload,
1199 "repository": payload::repository(&repo),
1200 "sender": payload::user(&a.actor.username),
1201 });
1202 let key = format!("repository_dispatch:{}", new_id("dsp", now_ms()));
1203 let mut started = 0u32;
1204 for file in read.files {
1205 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1206 let Some(trigger) = workflow.trigger("repository_dispatch") else { continue };
1207 if !trigger.wants_type(Some(&event_type)) || self.disabled(&repo.id, &file.path).await? {
1208 continue;
1209 }
1210 let subject = Subject {
1211 source: Self::repo_path(&repo),
1212 source_ref: Some(sha.clone()),
1213 git_ref: git_ref.clone(),
1214 sha: sha.clone(),
1215 head_ref: None,
1216 base_ref: None,
1217 pull: None,
1218 filter_ref: git_ref.clone(),
1219 paths: None,
1220 compare: None,
1221 payload: payload.clone(),
1222 title: event_type.clone(),
1223 trusted: true,
1224 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1225 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1226 };
1227 let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1228 let created = self
1229 .create_run(NewRun {
1230 repo: repo.clone(),
1231 path: file.path.clone(),
1232 source: file.source.clone(),
1233 workflow,
1234 info,
1235 action: Some(event_type.clone()),
1236 pull: None,
1237 title: subject.title.clone(),
1238 inputs: Map::new(),
1239 event_key: key.clone(),
1240 actor_id: Some(a.actor.id.clone()),
1241 actor: Some(a.actor.username.clone()),
1242 trusted: true,
1243 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1244 head_sha: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1245 })
1246 .await?;
1247 if created.is_some() {
1248 started += 1;
1249 }
1250 }
1251 Ok(Outcome::Ok(started))
1252 }
Prices are what g1t pays plus 20%, from the first second1253}
1254
1255#[derive(serde::Deserialize)]
1256#[serde(rename_all = "camelCase")]
1257pub struct MergeGroupArgs {
1258 pub repo_id: String,
1259 pub entry: String,
1260 pub sha: String,
1261 pub head_ref: String,
1262 #[serde(default)]
1263 pub base_sha: Option<String>,
1264 pub number: u32,
1265 #[serde(default)]
1266 pub ahead: Vec<u32>,
1267}
1268
1269impl Actions {
1270 /// `merge_group`: the merge queue built a state and its checks passed.
1271 /// Starts the workflows that run `on: merge_group` on it, as GitHub's
1272 /// queue does, and says how many started; the queue waits for their
1273 /// statuses on that commit.
1274 pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
1275 let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
1276 return Ok(Outcome::Ok(json!({ "runs": 0 })));
1277 };
1278 let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
1279 let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
1280 let payload = json!({
1281 "action": "checks_requested",
1282 "merge_group": {
1283 "head_sha": a.sha,
1284 "head_ref": a.head_ref,
1285 "base_sha": a.base_sha,
1286 "base_ref": format!("refs/heads/{}", repo.default_branch),
1287 "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
1288 },
1289 "repository": payload::repository(&repo),
1290 "sender": payload::user(&repo.namespace),
1291 });
1292 let mut started = 0u32;
1293 for file in read.files {
1294 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1295 let Some(trigger) = workflow.trigger("merge_group") else { continue };
1296 if !trigger.wants_type(Some("checks_requested")) || self.disabled(&repo.id, &file.path).await? {
1297 continue;
1298 }
1299 // Branch filters on merge_group name the branch it merges into.
1300 if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
1301 continue;
1302 }
1303 let ahead = if a.ahead.is_empty() {
1304 String::new()
1305 } else {
1306 format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
1307 };
1308 let subject = Subject {
1309 source: Self::repo_path(&repo),
1310 source_ref: Some(a.sha.clone()),
1311 git_ref: a.head_ref.clone(),
1312 sha: a.sha.clone(),
1313 head_ref: None,
1314 base_ref: Some(repo.default_branch.clone()),
1315 pull: Some(a.number),
1316 filter_ref: format!("refs/heads/{}", repo.default_branch),
1317 paths: None,
1318 compare: None,
1319 payload: payload.clone(),
1320 title: format!("Merge queue: #{}{ahead}", a.number),
1321 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1322 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1323 merge: None,
Prices are what g1t pays plus 20%, from the first second1324 };
1325 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
1326 let created = self
1327 .create_run(NewRun {
1328 repo: repo.clone(),
1329 path: file.path.clone(),
1330 source: file.source.clone(),
1331 workflow,
1332 info,
1333 action: Some("checks_requested".to_owned()),
1334 pull: Some(a.number),
1335 title: subject.title.clone(),
1336 inputs: Map::new(),
1337 event_key: format!("merge_group:{}:{}", a.entry, a.sha),
1338 actor_id: None,
1339 actor: None,
1340 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1341 approval: None,
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1342 head_sha: None,
Prices are what g1t pays plus 20%, from the first second1343 })
1344 .await?;
1345 if created.is_some() {
1346 started += 1;
1347 }
1348 }
1349 Ok(Outcome::Ok(json!({ "runs": started })))
1350 }
1351}
1352
1353/// The inputs of a manual run: what was given, checked against the
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91354/// workflow's declared inputs, with their defaults filled in. An input the
1355/// workflow does not declare is refused, as GitHub refuses it.
1356/// `environments` are the repository's, for `type: environment`.
1357fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> {
1358 let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect();
1359 if !unexpected.is_empty() {
1360 let names = serde_json::to_string(&unexpected).unwrap_or_default();
1361 let declared = if trigger.inputs.is_empty() {
1362 "The workflow declares no inputs.".to_owned()
1363 } else {
1364 format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", "))
1365 };
1366 return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}"));
1367 }
Prices are what g1t pays plus 20%, from the first second1368 let mut inputs = Map::new();
1369 for (name, spec) in &trigger.inputs {
1370 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
1371 let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
1372 let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
1373 let value = match value {
1374 Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
1375 Some(Value::Null) | None => match kind {
1376 "boolean" => Value::Bool(false),
1377 _ => Value::String(String::new()),
1378 },
1379 Some(value) => match kind {
1380 "boolean" => Value::Bool(match &value {
1381 Value::Bool(flag) => *flag,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91382 Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true,
1383 Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false,
1384 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")),
Prices are what g1t pays plus 20%, from the first second1385 }),
1386 "number" => match &value {
1387 Value::Number(_) => value,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91388 Value::String(text) => match number(text) {
Prices are what g1t pays plus 20%, from the first second1389 Some(number) => Value::Number(number),
1390 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1391 },
1392 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1393 },
1394 "choice" => {
1395 let text = g1t_actions::expr::to_text(&value);
1396 let options: Vec<String> =
1397 spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
1398 if !options.is_empty() && !options.contains(&text) {
1399 return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
1400 }
1401 Value::String(text)
1402 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91403 "environment" => {
1404 let text = g1t_actions::expr::to_text(&value);
1405 let wanted = text.trim().to_ascii_lowercase();
1406 if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) {
1407 return fail(
1408 FailureCode::Invalid,
1409 format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()),
1410 );
1411 }
1412 Value::String(text)
1413 }
Prices are what g1t pays plus 20%, from the first second1414 _ => Value::String(g1t_actions::expr::to_text(&value)),
1415 },
1416 };
1417 inputs.insert(name.clone(), value);
1418 }
1419 Outcome::Ok(inputs)
1420}
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1421
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91422/// A number input given as text: a whole number stays one.
1423fn number(text: &str) -> Option<serde_json::Number> {
1424 let text = text.trim();
1425 match text.parse::<i64>() {
1426 Ok(whole) => Some(whole.into()),
1427 Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64),
1428 }
1429}
1430
1431/// A manual run's inputs as `github.event.inputs` gives them: every one as
1432/// text, as GitHub's `workflow_dispatch` payload has them.
1433fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> {
1434 inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect()
1435}
1436
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1437#[cfg(test)]
1438mod tests {
1439 use super::*;
1440 use g1t_contracts::work::{Pull, g1t_author};
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1441
1442 #[test]
1443 fn every_event_a_workflow_runs_on_is_sent_to_this_queue() {
1444 for kind in g1t_contracts::webhooks::EVENT_TYPES {
1445 if !github_events(kind).is_empty() {
1446 assert!(
1447 g1t_contracts::subscribers::routed("SUBSCRIBER_ACTIONS", kind),
1448 "{kind} starts workflows but is not routed to actions (g1t_contracts::subscribers)"
1449 );
1450 }
1451 }
1452 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1453 use g1t_contracts::{Membership, PrincipalKind};
1454
A pull request's `pull_request` runs build the pull request merged into its base branch, as on GitHub. g1t keeps `refs/pull/<number>/head` at every open pull request's head (a fork's or working copy's head commit is copied into the repository) and `refs/pull/<number>/merge` at a test merge commit of that head into the base branch's latest commit, moved whenever the head is pushed to, the pull request is opened or reopened, or its base changes; both can be fetched from the repository. A run's `github.sha` and `GITHUB_SHA` are that merge commit, `github.event.pull_request.merge_commit_sha` names it, `actions/checkout` fetches it as `+<sha>:refs/remotes/pull/<number>/merge` from the repository itself, and the workflow files and local reusable workflows are read from it; the run, its statuses and its check runs stay on the head, so required checks hold merges as before, and `pull_request_target` is unchanged. A pull request with conflicts starts no `pull_request` workflows, as on GitHub, and its checks box says why. g1t makes the merge without a sandbox, merging a file both sides changed line by line as git does; what it cannot merge itself (a rename on one side against a change on the other, more than 300 changed files) builds the head as before. The Actions and Checks guides say so.1455 #[test]
1456 fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() {
1457 let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
1458 let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
1459 let unavailable = PullMergeRef::Unavailable { reason: "too large".into() };
1460 assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into()));
1461 assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into()));
1462 // GitHub runs no pull_request workflow while it conflicts.
1463 for action in ["opened", "synchronize", "reopened", "edited"] {
1464 assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing);
1465 }
1466 // Closing it, or reviewing it, still runs, on the head.
1467 assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head);
1468 assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head);
1469 // What g1t could not merge itself runs on the head.
1470 assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head);
1471 }
1472
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1473 fn repo() -> Repo {
1474 serde_json::from_value(json!({
1475 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
1476 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
1477 }))
1478 .unwrap()
1479 }
1480
1481 fn person(id: &str, username: &str) -> User {
1482 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
1483 }
1484
1485 fn made_for(asker: User) -> Pull {
1486 serde_json::from_value(json!({
1487 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
1488 "agent": "g1t", "runtime": "hosted", "status": "open",
1489 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
1490 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
1491 "supersededBy": null, "checkStatus": null,
1492 "author": g1t_author(), "requestedBy": asker,
1493 "createdAt": "", "updatedAt": ""
1494 }))
1495 .unwrap()
1496 }
1497
1498 #[test]
1499 fn g1t_s_change_for_someone_is_trusted_as_they_are() {
1500 // Stored people carry no memberships, so identity is asked about
1501 // them; being g1t's change gives it nothing more.
1502 let pull = made_for(person("usr_2", "ana"));
1503 assert!(!trusted_outright(pull.owner(), &repo()));
1504 // Someone known to be able to push is trusted at once.
1505 let mut member = person("usr_1", "syntaqx");
1506 member.workspaces.push(Membership::member("acme"));
1507 let pull = made_for(member);
1508 assert!(trusted_outright(pull.owner(), &repo()));
1509 }
1510
1511 #[test]
1512 fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() {
1513 let pull = made_for(person("usr_1", "syntaqx"));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1514 let event = payload::pull(&repo(), &pull);
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1515 assert_eq!(event["user"]["login"], "g1t");
1516 assert_eq!(event["user"]["type"], "Bot");
1517 assert_eq!(event["requested_by"]["login"], "syntaqx");
1518 assert_eq!(event["requested_by"]["type"], "User");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1519 let as_issue = payload::pull_as_issue(&repo(), &pull);
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1520 assert_eq!(as_issue["user"]["login"], "g1t");
1521 assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
1522 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1523
1524 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1525 fn releases_deployments_and_deleted_comments_read_as_githubs() {
1526 let release = payload::release(
1527 &repo(),
1528 &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false,
1529 "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }),
1530 );
1531 assert_eq!(release["tag_name"], "v1.2.0");
1532 assert_eq!(release["name"], "v1.2.0");
1533 assert_eq!(release["prerelease"], true);
1534 assert_eq!(release["author"]["login"], "ana");
1535 assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0");
1536 let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana",
1537 "production_environment": false, "created_at": "t", "updated_at": "t" });
1538 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);
1539 assert_eq!(status["state"], "success");
1540 assert_eq!(status["environment"], "staging");
1541 assert_eq!(status["environment_url"], "https://s.example");
1542 assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1543 let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1544 assert_eq!(gone["user"]["login"], "bo");
1545 assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1546 assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
1547 assert!(!is_commit("main"));
1548 }
1549
1550 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1551 fn a_pull_request_names_its_own_base_labels_and_milestone() {
1552 let mut pull = made_for(person("usr_1", "syntaqx"));
1553 let event = payload::pull(&repo(), &pull);
1554 assert_eq!(event["base"]["ref"], repo().default_branch);
1555 pull.base = Some("release/1.x".into());
1556 pull.labels = vec!["bug".into()];
1557 pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() });
1558 let event = payload::pull(&repo(), &pull);
1559 assert_eq!(event["base"]["ref"], "release/1.x");
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.1560 assert_eq!(event["labels"][0]["name"], "bug");
1561 assert!(event["labels"][0]["id"].is_u64());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1562 assert_eq!(event["milestone"]["title"], "1.1");
1563 let mut labeled = serde_json::json!({ "action": "labeled" });
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.1564 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 bar1565 assert_eq!(labeled["label"]["color"], "d73a4a");
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91566 let mut assigned = serde_json::json!({ "action": "assigned" });
A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.1567 payload::changed(&repo(), &mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91568 assert_eq!(assigned["assignee"]["login"], "bo");
1569 assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] })));
1570 assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
1571 }
1572
1573 fn dispatchable(yaml: &str) -> Trigger {
1574 workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone()
1575 }
1576
1577 fn given(value: Value) -> Map<String, Value> {
1578 value.as_object().unwrap().clone()
1579 }
1580
1581 const DISPATCH: &str = "on:
1582 workflow_dispatch:
1583 inputs:
1584 dry-run: { type: boolean, default: false }
1585 count: { type: number, default: 2 }
1586 target: { type: environment }
1587 level: { type: choice, options: [low, high], default: low }
1588 note: { required: false }
1589jobs:
1590 a:
1591 runs-on: x
1592 steps: [{ run: 'true' }]";
1593
1594 #[test]
1595 fn an_input_the_workflow_does_not_declare_is_refused() {
1596 let trigger = dispatchable(DISPATCH);
1597 let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else {
1598 panic!("an undeclared input was taken");
1599 };
1600 assert_eq!(failure.code, FailureCode::Invalid);
1601 assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message);
1602 assert!(failure.message.contains("`dry-run`"));
1603 let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]");
1604 let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() };
1605 assert!(failure.message.contains("declares no inputs"));
1606 assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty()));
1607 }
1608
1609 #[test]
1610 fn inputs_are_typed_and_the_event_has_them_as_text() {
1611 let trigger = dispatchable(DISPATCH);
1612 let environments = vec!["staging".to_owned(), "production".to_owned()];
1613 let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments)
1614 else {
1615 panic!("refused");
1616 };
1617 assert_eq!(inputs["dry-run"], json!(true));
1618 assert_eq!(inputs["count"], json!(3));
1619 assert_eq!(inputs["level"], json!("low"));
1620 assert_eq!(inputs["note"], json!(""));
1621 let event = event_inputs(&inputs);
1622 assert_eq!(event["dry-run"], json!("true"));
1623 assert_eq!(event["count"], json!("3"));
1624 assert_eq!(event["target"], json!("Staging"));
1625 let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() };
1626 assert_eq!(event_inputs(&defaults)["dry-run"], json!("false"));
1627 assert_eq!(defaults["count"], json!(2));
1628 let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) {
1629 Outcome::Fail(failure) => failure.message,
1630 Outcome::Ok(_) => panic!("taken"),
1631 };
1632 assert!(refused(json!({ "target": "qa" })).contains("none called `qa`"));
1633 assert!(refused(json!({ "dry-run": "yes" })).contains("true or false"));
1634 assert!(refused(json!({ "count": "many" })).contains("is a number"));
1635 assert!(refused(json!({ "level": "mid" })).contains("low, high"));
1636 }
1637
1638 #[test]
1639 fn create_and_delete_take_no_filters_and_name_their_ref() {
1640 assert!(unfiltered("create") && unfiltered("delete"));
1641 assert!(!unfiltered("push") && !unfiltered("pull_request"));
1642 assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x")));
1643 assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0")));
1644 assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None);
1645 assert_eq!(branch_or_tag("refs/notes/commits"), None);
1646 // A workflow can name `delete`. It takes no filters, as GitHub's
1647 // schema says, so writing one is refused when the workflow is read.
1648 assert!(workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").is_err());
1649 let workflow = workflow::parse("on:\n delete:\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1650 let trigger = workflow.trigger("delete").unwrap();
1651 assert!(trigger.wants_type(None));
1652 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 bar1653 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1654}

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