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

Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1//! 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};
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put10use 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};
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put12use 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;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put15use 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
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put27/// 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>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put54}
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
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put117impl 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) {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put135 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(
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put140 &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 },
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put147 )
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)))
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put154 }
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)> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put165 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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put176 },
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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put187 }
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))
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put191 }
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
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put209 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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put219 },
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>> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put231 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(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put237 },
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
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put275 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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put302 };
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?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put320 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);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put322 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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put338 })
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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put392 "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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put399 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();
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put403 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),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put407 "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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put422 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);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put425 }
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 };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put436 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}");
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put444 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()),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put486 };
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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put491 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()),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put495 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}"),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put498 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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put504 })
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 };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put536 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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put542 "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),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put546 },
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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put560 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)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put568 }
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);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put577 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();
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put597 }
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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put688 _ => 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
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put704 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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put711 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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put715 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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put735 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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put747 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?;
A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so.782 read.files = self.with_managed(&repo, read.files).await?;
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.783 read
Merge branch 'mirroring' into artifacts-mode784 };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put785 // A pull request's head runs each workflow once, however many
786 // events say it is there (marked ready, and pushed).
787 let key = match subject.pull {
788 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 timeouts789 // Reopened or made a draft again runs anew, at a head
790 // that may have run before.
791 let phase = match action {
792 Some("closed") => "closed".to_owned(),
793 Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id),
794 _ => "open".to_owned(),
795 };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put796 format!("{event_name}:{number}:{}:{phase}", subject.sha)
797 }
Merge branch 'worktree-agent-a3abfcce648e87dca'798 _ if event_name == "create" => format!("{}:create", event.id),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put799 _ => event.id.clone(),
800 };
801 self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender)
802 .await?;
803 }
804 Ok(())
805 }
806
807 #[allow(clippy::too_many_arguments)]
808 async fn start_matching(
809 &self,
810 repo: &Repo,
811 ws: &User,
812 read: Read,
813 subject: &mut Subject,
814 event_name: &str,
815 action: Option<&str>,
816 event_key: &str,
817 actor_id: Option<&str>,
818 sender: &str,
819 ) -> Result<()> {
820 for file in read.files {
821 let parsed = workflow::parse(&file.source);
822 let workflow = match parsed {
823 Ok(workflow) => workflow,
824 Err(problem) => {
825 // A push shows a broken workflow as a failed run, as GitHub does.
826 if event_name == "push" && file.source.contains("on") {
827 self.record_invalid(repo, &file.path, &file.source, subject, event_key, actor_id, sender, &problem)
828 .await?;
829 }
830 continue;
831 }
832 };
833 let Some(trigger) = workflow.trigger(event_name) else { continue };
834 // workflow_run follows the workflows it names.
835 if event_name == "workflow_run" {
836 let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default();
837 if !trigger.workflows.iter().any(|name| name == followed) {
838 continue;
839 }
840 }
841 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
842 continue;
843 }
A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so.844 // A workspace's required workflow runs whatever its row says.
845 if !file.required && self.disabled(&repo.id, &file.path).await? {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put846 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));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put854 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()),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put870 })
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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put883 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()),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put935 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.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put971 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()])?
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put979 .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 {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put985 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 };
A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so.997 // A managed workflow's row may be out of date while its
998 // repository waits to be synced again (managed.rs).
999 if g1t_actions::managed::is_managed(&row.path) && !self.synced(&repo.id).await? {
1000 continue;
1001 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1002 let Some(sha) = self.default_head(&repo).await? else { continue };
1003 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
1004 let mut subject = Subject {
1005 source: Self::repo_path(&repo),
1006 source_ref: None,
1007 git_ref: format!("refs/heads/{}", repo.default_branch),
1008 sha,
1009 head_ref: None,
1010 base_ref: None,
1011 pull: None,
1012 filter_ref: String::new(),
1013 paths: None,
1014 compare: None,
1015 payload,
1016 title: format!("Scheduled: {cron}"),
1017 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1018 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.1019 merge: None,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1020 };
1021 subject.filter_ref = subject.git_ref.clone();
1022 let info = self.run_info(&repo, &workflow, "schedule", &subject, &repo.namespace, None);
1023 self.create_run(NewRun {
1024 repo: repo.clone(),
1025 path: row.path.clone(),
1026 source: row.source.clone(),
1027 workflow,
1028 info,
1029 action: None,
1030 pull: None,
1031 title: subject.title.clone(),
1032 inputs: Map::new(),
1033 event_key: format!("schedule:{minute}"),
1034 actor_id: None,
1035 actor: None,
1036 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1037 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.1038 head_sha: None,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1039 })
1040 .await?;
1041 }
1042 Ok(())
1043 }
1044
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1045 /// `dispatch`: someone with the Write role runs a workflow that has
1046 /// `workflow_dispatch`.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1047 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 look1048 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
1049 if repo.archived() {
1050 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1051 }
1052 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1053 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1054 };
1055 let git_ref = a.git_ref.clone().unwrap_or_else(|| repo.default_branch.clone());
1056 let full_ref = if git_ref.starts_with("refs/") {
1057 git_ref.clone()
1058 } else {
1059 // A branch if there is one by that name, otherwise a tag.
1060 let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
1061 &self.repos,
1062 "branches",
1063 &g1t_contracts::repos::BranchesArgs {
1064 path: Self::repo_path(&repo),
1065 viewer: Some(ws.clone()),
1066 },
1067 )
1068 .await?;
1069 let is_branch = branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == git_ref);
1070 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
1071 };
1072 let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
Merge branch 'mirroring' into artifacts-mode1073 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
1074 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
1075 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1076 }
1077 let read = self.read_for(&repo, &ws, Some(&short), policy.github).await?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1078 let Some(sha) = read.head.clone() else {
1079 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
1080 };
1081 // A workflow is named by its file (`build.yml`), its path, or its id
1082 // (`wfl_…`), which stands for the path it was read from.
1083 let by_id = if a.workflow.starts_with("wfl_") {
1084 self.db
1085 .prepare("SELECT * FROM workflows WHERE repo_id = ? AND id = ?")
1086 .bind(&[repo.id.as_str().into(), a.workflow.as_str().into()])?
1087 .first::<WorkflowRow>(None)
1088 .await?
1089 .map(|row| row.path)
1090 } else {
1091 None
1092 };
1093 let named = by_id.as_deref().unwrap_or(&a.workflow);
1094 let wanted = named.trim_start_matches(".g1t/workflows/");
1095 let Some(file) = read.files.iter().find(|file| {
1096 file.path.rsplit('/').next() == Some(wanted) || file.path == named
1097 }) else {
1098 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {wanted} on {short}.")));
1099 };
1100 let workflow = match workflow::parse(&file.source) {
1101 Ok(workflow) => workflow,
1102 Err(problem) => return Ok(fail(FailureCode::Invalid, format!("The workflow does not read: {problem}"))),
1103 };
1104 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
1105 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
1106 };
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91107 // `type: environment` names one of the repository's environments.
1108 let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) {
1109 self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect()
1110 } else {
1111 Vec::new()
1112 };
1113 let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1114 let payload = json!({
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91115 // As GitHub gives them here: text, `true` and `false` included.
1116 // The `inputs` context keeps booleans and numbers.
1117 "inputs": event_inputs(&inputs),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1118 "ref": full_ref,
1119 "repository": payload::repository(&repo),
1120 "sender": payload::user(&a.actor.username),
1121 "workflow": file.path,
1122 });
1123 let subject = Subject {
1124 source: Self::repo_path(&repo),
1125 source_ref: Some(sha.clone()),
1126 git_ref: full_ref.clone(),
1127 sha,
1128 head_ref: None,
1129 base_ref: None,
1130 pull: None,
1131 filter_ref: full_ref,
1132 paths: None,
1133 compare: None,
1134 payload,
1135 title: format!("{} run by {}", workflow.display_name(&file.path), a.actor.username),
1136 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1137 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.1138 merge: None,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1139 };
1140 let info = self.run_info(&repo, &workflow, "workflow_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1141 let created = self
1142 .create_run(NewRun {
1143 repo: repo.clone(),
1144 path: file.path.clone(),
1145 source: file.source.clone(),
1146 workflow,
1147 info,
1148 action: None,
1149 pull: None,
1150 title: subject.title.clone(),
1151 inputs,
1152 event_key: format!("dispatch:{}", new_id("dsp", now_ms())),
1153 actor_id: Some(a.actor.id.clone()),
1154 actor: Some(a.actor.username.clone()),
1155 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1156 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.1157 head_sha: None,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1158 })
1159 .await?;
1160 match created {
1161 Some(id) => self.run_summary(&id).await,
1162 None => Ok(fail(FailureCode::Conflict, "It did not start.")),
1163 }
1164 }
Merge branch 'worktree-agent-a3abfcce648e87dca'1165
1166 /// `repository_dispatch`: an outside event, by name, starts the default
1167 /// branch's workflows that run `on: repository_dispatch` with that type
1168 /// (or with no `types`). A workflow job's token may send one: this,
1169 /// with `workflow_dispatch`, is how a workflow starts another.
1170 pub async fn repository_dispatch(&self, a: RepositoryDispatchArgs) -> Result<Outcome<u32>> {
1171 let repo = check!(self.may(&a.actor, &a.repo, Capability::Push).await?);
1172 if repo.archived() {
1173 return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name)));
1174 }
1175 let event_type = a.event_type.trim().to_owned();
1176 if event_type.is_empty() || event_type.chars().count() > 100 {
1177 return Ok(fail(FailureCode::Invalid, "event_type is 1 to 100 characters."));
1178 }
1179 let client_payload = match a.client_payload {
1180 Value::Null => json!({}),
1181 Value::Object(map) if map.len() <= 10 => Value::Object(map),
1182 Value::Object(_) => return Ok(fail(FailureCode::Invalid, "client_payload has at most 10 top-level properties.")),
1183 _ => return Ok(fail(FailureCode::Invalid, "client_payload is a JSON object.")),
1184 };
1185 if serde_json::to_string(&client_payload).map_or(0, |text| text.len()) > 64 * 1024 {
1186 return Ok(fail(FailureCode::Invalid, "client_payload is at most 64 KB."));
1187 }
1188 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
1189 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
1190 };
Merge branch 'mirroring' into artifacts-mode1191 let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
1192 if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
1193 return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
1194 }
1195 let read = self.read_for(&repo, &ws, Some(&repo.default_branch), policy.github).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'1196 let Some(sha) = read.head.clone() else {
1197 return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet."));
1198 };
1199 let git_ref = format!("refs/heads/{}", repo.default_branch);
1200 let payload = json!({
1201 "action": event_type,
1202 "branch": repo.default_branch,
1203 "client_payload": client_payload,
1204 "repository": payload::repository(&repo),
1205 "sender": payload::user(&a.actor.username),
1206 });
1207 let key = format!("repository_dispatch:{}", new_id("dsp", now_ms()));
1208 let mut started = 0u32;
1209 for file in read.files {
1210 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1211 let Some(trigger) = workflow.trigger("repository_dispatch") else { continue };
1212 if !trigger.wants_type(Some(&event_type)) || self.disabled(&repo.id, &file.path).await? {
1213 continue;
1214 }
1215 let subject = Subject {
1216 source: Self::repo_path(&repo),
1217 source_ref: Some(sha.clone()),
1218 git_ref: git_ref.clone(),
1219 sha: sha.clone(),
1220 head_ref: None,
1221 base_ref: None,
1222 pull: None,
1223 filter_ref: git_ref.clone(),
1224 paths: None,
1225 compare: None,
1226 payload: payload.clone(),
1227 title: event_type.clone(),
1228 trusted: true,
1229 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.1230 merge: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1231 };
1232 let info = self.run_info(&repo, &workflow, "repository_dispatch", &subject, &a.actor.username, Some(&a.actor.id));
1233 let created = self
1234 .create_run(NewRun {
1235 repo: repo.clone(),
1236 path: file.path.clone(),
1237 source: file.source.clone(),
1238 workflow,
1239 info,
1240 action: Some(event_type.clone()),
1241 pull: None,
1242 title: subject.title.clone(),
1243 inputs: Map::new(),
1244 event_key: key.clone(),
1245 actor_id: Some(a.actor.id.clone()),
1246 actor: Some(a.actor.username.clone()),
1247 trusted: true,
1248 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.1249 head_sha: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1250 })
1251 .await?;
1252 if created.is_some() {
1253 started += 1;
1254 }
1255 }
1256 Ok(Outcome::Ok(started))
1257 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1258}
1259
1260#[derive(serde::Deserialize)]
1261#[serde(rename_all = "camelCase")]
1262pub struct MergeGroupArgs {
1263 pub repo_id: String,
1264 pub entry: String,
1265 pub sha: String,
1266 pub head_ref: String,
1267 #[serde(default)]
1268 pub base_sha: Option<String>,
1269 pub number: u32,
1270 #[serde(default)]
1271 pub ahead: Vec<u32>,
1272}
1273
1274impl Actions {
1275 /// `merge_group`: the merge queue built a state and its checks passed.
1276 /// Starts the workflows that run `on: merge_group` on it, as GitHub's
1277 /// queue does, and says how many started; the queue waits for their
1278 /// statuses on that commit.
1279 pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
1280 let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
1281 return Ok(Outcome::Ok(json!({ "runs": 0 })));
1282 };
A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so.1283 let mut read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
1284 // A required workflow's check holds the queue too (managed.rs).
1285 read.files = self.with_managed(&repo, read.files).await?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1286 let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
1287 let payload = json!({
1288 "action": "checks_requested",
1289 "merge_group": {
1290 "head_sha": a.sha,
1291 "head_ref": a.head_ref,
1292 "base_sha": a.base_sha,
1293 "base_ref": format!("refs/heads/{}", repo.default_branch),
1294 "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
1295 },
1296 "repository": payload::repository(&repo),
1297 "sender": payload::user(&repo.namespace),
1298 });
1299 let mut started = 0u32;
1300 for file in read.files {
1301 let Ok(workflow) = workflow::parse(&file.source) else { continue };
1302 let Some(trigger) = workflow.trigger("merge_group") else { continue };
A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so.1303 if !trigger.wants_type(Some("checks_requested")) || (!file.required && self.disabled(&repo.id, &file.path).await?) {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1304 continue;
1305 }
1306 // Branch filters on merge_group name the branch it merges into.
1307 if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
1308 continue;
1309 }
1310 let ahead = if a.ahead.is_empty() {
1311 String::new()
1312 } else {
1313 format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
1314 };
1315 let subject = Subject {
1316 source: Self::repo_path(&repo),
1317 source_ref: Some(a.sha.clone()),
1318 git_ref: a.head_ref.clone(),
1319 sha: a.sha.clone(),
1320 head_ref: None,
1321 base_ref: Some(repo.default_branch.clone()),
1322 pull: Some(a.number),
1323 filter_ref: format!("refs/heads/{}", repo.default_branch),
1324 paths: None,
1325 compare: None,
1326 payload: payload.clone(),
1327 title: format!("Merge queue: #{}{ahead}", a.number),
1328 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1329 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.1330 merge: None,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1331 };
1332 let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
1333 let created = self
1334 .create_run(NewRun {
1335 repo: repo.clone(),
1336 path: file.path.clone(),
1337 source: file.source.clone(),
1338 workflow,
1339 info,
1340 action: Some("checks_requested".to_owned()),
1341 pull: Some(a.number),
1342 title: subject.title.clone(),
1343 inputs: Map::new(),
1344 event_key: format!("merge_group:{}:{}", a.entry, a.sha),
1345 actor_id: None,
1346 actor: None,
1347 trusted: true,
Merge branch 'worktree-agent-a3abfcce648e87dca'1348 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.1349 head_sha: None,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1350 })
1351 .await?;
1352 if created.is_some() {
1353 started += 1;
1354 }
1355 }
1356 Ok(Outcome::Ok(json!({ "runs": started })))
1357 }
1358}
1359
1360/// The inputs of a manual run: what was given, checked against the
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91361/// workflow's declared inputs, with their defaults filled in. An input the
1362/// workflow does not declare is refused, as GitHub refuses it.
1363/// `environments` are the repository's, for `type: environment`.
1364fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> {
1365 let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect();
1366 if !unexpected.is_empty() {
1367 let names = serde_json::to_string(&unexpected).unwrap_or_default();
1368 let declared = if trigger.inputs.is_empty() {
1369 "The workflow declares no inputs.".to_owned()
1370 } else {
1371 format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", "))
1372 };
1373 return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}"));
1374 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1375 let mut inputs = Map::new();
1376 for (name, spec) in &trigger.inputs {
1377 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
1378 let value = given.get(name).cloned().or_else(|| spec.get("default").cloned());
1379 let required = spec.get("required").and_then(Value::as_bool).unwrap_or(false);
1380 let value = match value {
1381 Some(Value::Null) | None if required => return fail(FailureCode::Invalid, format!("The input `{name}` is required.")),
1382 Some(Value::Null) | None => match kind {
1383 "boolean" => Value::Bool(false),
1384 _ => Value::String(String::new()),
1385 },
1386 Some(value) => match kind {
1387 "boolean" => Value::Bool(match &value {
1388 Value::Bool(flag) => *flag,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91389 Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true,
1390 Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false,
1391 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1392 }),
1393 "number" => match &value {
1394 Value::Number(_) => value,
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91395 Value::String(text) => match number(text) {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1396 Some(number) => Value::Number(number),
1397 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1398 },
1399 _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
1400 },
1401 "choice" => {
1402 let text = g1t_actions::expr::to_text(&value);
1403 let options: Vec<String> =
1404 spec.get("options").and_then(Value::as_array).map(|o| o.iter().map(g1t_actions::expr::to_text).collect()).unwrap_or_default();
1405 if !options.is_empty() && !options.contains(&text) {
1406 return fail(FailureCode::Invalid, format!("The input `{name}` is one of {}.", options.join(", ")));
1407 }
1408 Value::String(text)
1409 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91410 "environment" => {
1411 let text = g1t_actions::expr::to_text(&value);
1412 let wanted = text.trim().to_ascii_lowercase();
1413 if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) {
1414 return fail(
1415 FailureCode::Invalid,
1416 format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()),
1417 );
1418 }
1419 Value::String(text)
1420 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1421 _ => Value::String(g1t_actions::expr::to_text(&value)),
1422 },
1423 };
1424 inputs.insert(name.clone(), value);
1425 }
1426 Outcome::Ok(inputs)
1427}
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1428
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91429/// A number input given as text: a whole number stays one.
1430fn number(text: &str) -> Option<serde_json::Number> {
1431 let text = text.trim();
1432 match text.parse::<i64>() {
1433 Ok(whole) => Some(whole.into()),
1434 Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64),
1435 }
1436}
1437
1438/// A manual run's inputs as `github.event.inputs` gives them: every one as
1439/// text, as GitHub's `workflow_dispatch` payload has them.
1440fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> {
1441 inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect()
1442}
1443
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1444#[cfg(test)]
1445mod tests {
1446 use super::*;
1447 use g1t_contracts::work::{Pull, g1t_author};
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1448
1449 #[test]
1450 fn every_event_a_workflow_runs_on_is_sent_to_this_queue() {
1451 for kind in g1t_contracts::webhooks::EVENT_TYPES {
1452 if !github_events(kind).is_empty() {
1453 assert!(
1454 g1t_contracts::subscribers::routed("SUBSCRIBER_ACTIONS", kind),
1455 "{kind} starts workflows but is not routed to actions (g1t_contracts::subscribers)"
1456 );
1457 }
1458 }
1459 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1460 use g1t_contracts::{Membership, PrincipalKind};
1461
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.1462 #[test]
1463 fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() {
1464 let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
1465 let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
1466 let unavailable = PullMergeRef::Unavailable { reason: "too large".into() };
1467 assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into()));
1468 assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into()));
1469 // GitHub runs no pull_request workflow while it conflicts.
1470 for action in ["opened", "synchronize", "reopened", "edited"] {
1471 assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing);
1472 }
1473 // Closing it, or reviewing it, still runs, on the head.
1474 assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head);
1475 assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head);
1476 // What g1t could not merge itself runs on the head.
1477 assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head);
1478 }
1479
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1480 fn repo() -> Repo {
1481 serde_json::from_value(json!({
1482 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
1483 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
1484 }))
1485 .unwrap()
1486 }
1487
1488 fn person(id: &str, username: &str) -> User {
1489 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
1490 }
1491
1492 fn made_for(asker: User) -> Pull {
1493 serde_json::from_value(json!({
1494 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
1495 "agent": "g1t", "runtime": "hosted", "status": "open",
1496 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
1497 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
1498 "supersededBy": null, "checkStatus": null,
1499 "author": g1t_author(), "requestedBy": asker,
1500 "createdAt": "", "updatedAt": ""
1501 }))
1502 .unwrap()
1503 }
1504
1505 #[test]
1506 fn g1t_s_change_for_someone_is_trusted_as_they_are() {
1507 // Stored people carry no memberships, so identity is asked about
1508 // them; being g1t's change gives it nothing more.
1509 let pull = made_for(person("usr_2", "ana"));
1510 assert!(!trusted_outright(pull.owner(), &repo()));
1511 // Someone known to be able to push is trusted at once.
1512 let mut member = person("usr_1", "syntaqx");
1513 member.workspaces.push(Membership::member("acme"));
1514 let pull = made_for(member);
1515 assert!(trusted_outright(pull.owner(), &repo()));
1516 }
1517
1518 #[test]
1519 fn the_payload_names_g1t_as_its_user_and_who_asked_for_it() {
1520 let pull = made_for(person("usr_1", "syntaqx"));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1521 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 rights1522 assert_eq!(event["user"]["login"], "g1t");
1523 assert_eq!(event["user"]["type"], "Bot");
1524 assert_eq!(event["requested_by"]["login"], "syntaqx");
1525 assert_eq!(event["requested_by"]["type"], "User");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1526 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 rights1527 assert_eq!(as_issue["user"]["login"], "g1t");
1528 assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
1529 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1530
1531 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1532 fn releases_deployments_and_deleted_comments_read_as_githubs() {
1533 let release = payload::release(
1534 &repo(),
1535 &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false,
1536 "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }),
1537 );
1538 assert_eq!(release["tag_name"], "v1.2.0");
1539 assert_eq!(release["name"], "v1.2.0");
1540 assert_eq!(release["prerelease"], true);
1541 assert_eq!(release["author"]["login"], "ana");
1542 assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0");
1543 let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana",
1544 "production_environment": false, "created_at": "t", "updated_at": "t" });
1545 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);
1546 assert_eq!(status["state"], "success");
1547 assert_eq!(status["environment"], "staging");
1548 assert_eq!(status["environment_url"], "https://s.example");
1549 assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1550 let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1551 assert_eq!(gone["user"]["login"], "bo");
1552 assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1553 assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
1554 assert!(!is_commit("main"));
1555 }
1556
1557 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1558 fn a_pull_request_names_its_own_base_labels_and_milestone() {
1559 let mut pull = made_for(person("usr_1", "syntaqx"));
1560 let event = payload::pull(&repo(), &pull);
1561 assert_eq!(event["base"]["ref"], repo().default_branch);
1562 pull.base = Some("release/1.x".into());
1563 pull.labels = vec!["bug".into()];
1564 pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() });
1565 let event = payload::pull(&repo(), &pull);
1566 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.1567 assert_eq!(event["labels"][0]["name"], "bug");
1568 assert!(event["labels"][0]["id"].is_u64());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1569 assert_eq!(event["milestone"]["title"], "1.1");
1570 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.1571 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 bar1572 assert_eq!(labeled["label"]["color"], "d73a4a");
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91573 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.1574 payload::changed(&repo(), &mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r91575 assert_eq!(assigned["assignee"]["login"], "bo");
1576 assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] })));
1577 assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
1578 }
1579
1580 fn dispatchable(yaml: &str) -> Trigger {
1581 workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone()
1582 }
1583
1584 fn given(value: Value) -> Map<String, Value> {
1585 value.as_object().unwrap().clone()
1586 }
1587
1588 const DISPATCH: &str = "on:
1589 workflow_dispatch:
1590 inputs:
1591 dry-run: { type: boolean, default: false }
1592 count: { type: number, default: 2 }
1593 target: { type: environment }
1594 level: { type: choice, options: [low, high], default: low }
1595 note: { required: false }
1596jobs:
1597 a:
1598 runs-on: x
1599 steps: [{ run: 'true' }]";
1600
1601 #[test]
1602 fn an_input_the_workflow_does_not_declare_is_refused() {
1603 let trigger = dispatchable(DISPATCH);
1604 let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else {
1605 panic!("an undeclared input was taken");
1606 };
1607 assert_eq!(failure.code, FailureCode::Invalid);
1608 assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message);
1609 assert!(failure.message.contains("`dry-run`"));
1610 let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]");
1611 let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() };
1612 assert!(failure.message.contains("declares no inputs"));
1613 assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty()));
1614 }
1615
1616 #[test]
1617 fn inputs_are_typed_and_the_event_has_them_as_text() {
1618 let trigger = dispatchable(DISPATCH);
1619 let environments = vec!["staging".to_owned(), "production".to_owned()];
1620 let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments)
1621 else {
1622 panic!("refused");
1623 };
1624 assert_eq!(inputs["dry-run"], json!(true));
1625 assert_eq!(inputs["count"], json!(3));
1626 assert_eq!(inputs["level"], json!("low"));
1627 assert_eq!(inputs["note"], json!(""));
1628 let event = event_inputs(&inputs);
1629 assert_eq!(event["dry-run"], json!("true"));
1630 assert_eq!(event["count"], json!("3"));
1631 assert_eq!(event["target"], json!("Staging"));
1632 let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() };
1633 assert_eq!(event_inputs(&defaults)["dry-run"], json!("false"));
1634 assert_eq!(defaults["count"], json!(2));
1635 let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) {
1636 Outcome::Fail(failure) => failure.message,
1637 Outcome::Ok(_) => panic!("taken"),
1638 };
1639 assert!(refused(json!({ "target": "qa" })).contains("none called `qa`"));
1640 assert!(refused(json!({ "dry-run": "yes" })).contains("true or false"));
1641 assert!(refused(json!({ "count": "many" })).contains("is a number"));
1642 assert!(refused(json!({ "level": "mid" })).contains("low, high"));
1643 }
1644
1645 #[test]
1646 fn create_and_delete_take_no_filters_and_name_their_ref() {
1647 assert!(unfiltered("create") && unfiltered("delete"));
1648 assert!(!unfiltered("push") && !unfiltered("pull_request"));
1649 assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x")));
1650 assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0")));
1651 assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None);
1652 assert_eq!(branch_or_tag("refs/notes/commits"), None);
1653 // A workflow can name `delete`. It takes no filters, as GitHub's
1654 // schema says, so writing one is refused when the workflow is read.
1655 assert!(workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").is_err());
1656 let workflow = workflow::parse("on:\n delete:\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1657 let trigger = workflow.trigger("delete").unwrap();
1658 assert!(trigger.wants_type(None));
1659 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 bar1660 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1661}

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