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
299 linesCodeBlameRaw
1//! g1t's events as GitHub's: which event and activity type each one is,
2//! and the `github` context and `GITHUB_*` variables a run sees.
3
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value, json};
6
7/// The GitHub event and activity type for a g1t event, if it has one.
8/// A g1t event can be more than one GitHub event: a pull request opening
9/// is `pull_request` and `pull_request_target`. A new branch or tag is
10/// `push` and `create`; services/actions adds `create` for a `git.push`
11/// with no `before`.
12pub fn github_events(kind: &str) -> Vec<(&'static str, Option<&'static str>)> {
13 let pull = |action| vec![("pull_request", Some(action)), ("pull_request_target", Some(action))];
14 match kind {
15 "git.push" => vec![("push", None)],
16 "git.deleted" => vec![("delete", None)],
17 "pull.opened" => pull("opened"),
18 "pull.updated" => pull("synchronize"),
19 "pull.ready" => pull("ready_for_review"),
20 "pull.reopened" => pull("reopened"),
21 "pull.converted_to_draft" => pull("converted_to_draft"),
22 "pull.closed" | "pull.merged" => pull("closed"),
23 "pull.assigned" => pull("assigned"),
24 "pull.review_requested" => pull("review_requested"),
25 "pull.review_request_removed" => pull("review_request_removed"),
26 "pull.labeled" => pull("labeled"),
27 "pull.unlabeled" => pull("unlabeled"),
28 "pull.milestoned" => pull("milestoned"),
29 "pull.demilestoned" => pull("demilestoned"),
30 "pull.base_changed" => pull("edited"),
31 "issue.opened" => vec![("issues", Some("opened"))],
32 "issue.updated" => vec![("issues", Some("edited"))],
33 "issue.closed" => vec![("issues", Some("closed"))],
34 "issue.reopened" => vec![("issues", Some("reopened"))],
35 "issue.assigned" => vec![("issues", Some("assigned"))],
36 "issue.labeled" => vec![("issues", Some("labeled"))],
37 "issue.unlabeled" => vec![("issues", Some("unlabeled"))],
38 "issue.milestoned" => vec![("issues", Some("milestoned"))],
39 "issue.demilestoned" => vec![("issues", Some("demilestoned"))],
40 "comment.created" => vec![("issue_comment", Some("created"))],
41 "comment.edited" => vec![("issue_comment", Some("edited"))],
42 "comment.deleted" => vec![("issue_comment", Some("deleted"))],
43 "release.created" => vec![("release", Some("created"))],
44 "release.published" => vec![("release", Some("published"))],
45 "release.released" => vec![("release", Some("released"))],
46 "release.prereleased" => vec![("release", Some("prereleased"))],
47 "release.edited" => vec![("release", Some("edited"))],
48 "release.unpublished" => vec![("release", Some("unpublished"))],
49 "release.deleted" => vec![("release", Some("deleted"))],
50 "deployment.created" => vec![("deployment", None)],
51 "deployment_status.created" => vec![("deployment_status", Some("created"))],
52 "review.completed" => vec![("pull_request_review", Some("submitted"))],
53 "workflow.completed" => vec![("workflow_run", Some("completed"))],
54 _ => Vec::new(),
55 }
56}
57
58/// What a run is about: enough to fill the `github` context.
59#[derive(Clone, Debug, Default, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct RunInfo {
62 /// `acme/web`.
63 pub repository: String,
64 pub repository_id: String,
65 pub default_branch: String,
66 pub event_name: String,
67 /// The webhook-shaped payload, `github.event`.
68 pub event: Value,
69 /// `refs/heads/main`, `refs/tags/v1`, `refs/pull/3/merge`.
70 pub git_ref: String,
71 pub sha: String,
72 /// For pull requests: the head and base branches.
73 pub head_ref: Option<String>,
74 pub base_ref: Option<String>,
75 pub actor: String,
76 pub actor_id: String,
77 pub triggering_actor: String,
78 pub run_id: String,
79 pub run_number: u64,
80 pub run_attempt: u64,
81 /// The workflow's name.
82 pub workflow: String,
83 /// `.g1t/workflows/ci.yml`.
84 pub workflow_path: String,
85 pub server_url: String,
86 pub api_url: String,
87}
88
89impl RunInfo {
90 pub fn ref_name(&self) -> String {
91 self.git_ref
92 .strip_prefix("refs/heads/")
93 .or_else(|| self.git_ref.strip_prefix("refs/tags/"))
94 .or_else(|| self.git_ref.strip_prefix("refs/"))
95 .unwrap_or(&self.git_ref)
96 .to_owned()
97 }
98
99 pub fn ref_type(&self) -> &'static str {
100 if self.git_ref.starts_with("refs/tags/") { "tag" } else { "branch" }
101 }
102
103 fn owner(&self) -> &str {
104 self.repository.split('/').next().unwrap_or_default()
105 }
106
107 /// The `github` context for a job. `token` is `github.token` (and
108 /// `secrets.GITHUB_TOKEN`); `job` is the job's id.
109 pub fn context(&self, job: &str, token: &str, action: Option<&str>) -> Value {
110 json!({
111 "action": action.unwrap_or_default(),
112 "action_path": "",
113 "action_ref": "",
114 "action_repository": "",
115 "actor": self.actor,
116 "actor_id": self.actor_id,
117 "api_url": self.api_url,
118 "base_ref": self.base_ref.clone().unwrap_or_default(),
119 "env": "",
120 "event": self.event,
121 "event_name": self.event_name,
122 "event_path": "/home/runner/_temp/event.json",
123 "graphql_url": "",
124 "head_ref": self.head_ref.clone().unwrap_or_default(),
125 "job": job,
126 "path": "",
127 "ref": self.git_ref,
128 "ref_name": self.ref_name(),
129 "ref_protected": self.ref_name() == self.default_branch,
130 "ref_type": self.ref_type(),
131 "repository": self.repository,
132 "repository_id": self.repository_id,
133 "repository_owner": self.owner(),
134 "repository_owner_id": "",
135 "repositoryUrl": format!("{}/{}.git", self.server_url, self.repository),
136 "retention_days": 14,
137 "run_attempt": self.run_attempt.to_string(),
138 "run_id": self.run_id,
139 "run_number": self.run_number.to_string(),
140 "secret_source": "Actions",
141 "server_url": self.server_url,
142 "sha": self.sha,
143 "token": token,
144 "triggering_actor": self.triggering_actor,
145 "workflow": self.workflow,
146 "workflow_ref": format!("{}/{}@{}", self.repository, self.workflow_path, self.git_ref),
147 "workflow_sha": self.sha,
148 "workspace": WORKSPACE,
149 })
150 }
151
152 /// The `GITHUB_*` and `RUNNER_*` variables every step gets.
153 pub fn variables(&self, job: &str) -> Map<String, Value> {
154 let mut vars = Map::new();
155 let mut set = |key: &str, value: String| {
156 vars.insert(key.to_owned(), Value::String(value));
157 };
158 set("CI", "true".into());
159 set("GITHUB_ACTIONS", "true".into());
160 set("G1T", "true".into());
161 set("GITHUB_ACTOR", self.actor.clone());
162 set("GITHUB_ACTOR_ID", self.actor_id.clone());
163 set("GITHUB_API_URL", self.api_url.clone());
164 set("GITHUB_BASE_REF", self.base_ref.clone().unwrap_or_default());
165 set("GITHUB_EVENT_NAME", self.event_name.clone());
166 set("GITHUB_EVENT_PATH", "/home/runner/_temp/event.json".into());
167 set("GITHUB_GRAPHQL_URL", String::new());
168 set("GITHUB_HEAD_REF", self.head_ref.clone().unwrap_or_default());
169 set("GITHUB_JOB", job.to_owned());
170 set("GITHUB_REF", self.git_ref.clone());
171 set("GITHUB_REF_NAME", self.ref_name());
172 set("GITHUB_REF_PROTECTED", (self.ref_name() == self.default_branch).to_string());
173 set("GITHUB_REF_TYPE", self.ref_type().into());
174 set("GITHUB_REPOSITORY", self.repository.clone());
175 set("GITHUB_REPOSITORY_ID", self.repository_id.clone());
176 set("GITHUB_REPOSITORY_OWNER", self.owner().to_owned());
177 set("GITHUB_RETENTION_DAYS", "14".into());
178 set("GITHUB_RUN_ATTEMPT", self.run_attempt.to_string());
179 set("GITHUB_RUN_ID", self.run_id.clone());
180 set("GITHUB_RUN_NUMBER", self.run_number.to_string());
181 set("GITHUB_SERVER_URL", self.server_url.clone());
182 set("GITHUB_SHA", self.sha.clone());
183 set("GITHUB_TRIGGERING_ACTOR", self.triggering_actor.clone());
184 set("GITHUB_WORKFLOW", self.workflow.clone());
185 set("GITHUB_WORKFLOW_REF", format!("{}/{}@{}", self.repository, self.workflow_path, self.git_ref));
186 set("GITHUB_WORKFLOW_SHA", self.sha.clone());
187 set("GITHUB_WORKSPACE", WORKSPACE.into());
188 set("RUNNER_ARCH", "X64".into());
189 set("RUNNER_NAME", "g1t".into());
190 set("RUNNER_OS", "Linux".into());
191 set("RUNNER_TEMP", "/home/runner/_temp".into());
192 set("RUNNER_TOOL_CACHE", "/home/runner/_tool".into());
193 set("RUNNER_ENVIRONMENT", "github-hosted".into());
194 vars
195 }
196}
197
198/// Where a job's repository is checked out, as on GitHub's runners.
199pub const WORKSPACE: &str = "/home/runner/work/repo";
200
201/// The `runner` context.
202/// Whether a head commit's `message` asks, as GitHub lets it, that the
203/// `push` and `pull_request` workflows it would start don't run: `[skip ci]`,
204/// `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]` anywhere in
205/// it, or a `skip-checks: true` trailer.
206pub fn skips_ci(message: &str) -> bool {
207 let lower = message.to_ascii_lowercase();
208 if ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"].iter().any(|marker| lower.contains(marker)) {
209 return true;
210 }
211 // Trailers are the last paragraph, after a blank line.
212 let trimmed = lower.trim_end();
213 let Some((_, trailers)) = trimmed.rsplit_once("\n\n") else { return false };
214 trailers.lines().any(|line| line.split_once(':').is_some_and(|(key, value)| key.trim() == "skip-checks" && value.trim() == "true"))
215}
216
217pub fn runner_context() -> Value {
218 json!({
219 "name": "g1t",
220 "os": "Linux",
221 "arch": "X64",
222 "temp": "/home/runner/_temp",
223 "tool_cache": "/home/runner/_tool",
224 "environment": "github-hosted",
225 "debug": "",
226 })
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn g1t_events_are_github_events() {
235 assert_eq!(github_events("git.push"), [("push", None)]);
236 assert_eq!(github_events("git.deleted"), [("delete", None)]);
237 assert_eq!(github_events("pull.labeled")[1], ("pull_request_target", Some("labeled")));
238 assert_eq!(github_events("pull.assigned")[0], ("pull_request", Some("assigned")));
239 assert_eq!(github_events("issue.unlabeled"), [("issues", Some("unlabeled"))]);
240 assert_eq!(github_events("issue.assigned"), [("issues", Some("assigned"))]);
241 assert_eq!(github_events("pull.updated")[0], ("pull_request", Some("synchronize")));
242 assert_eq!(github_events("pull.merged")[1], ("pull_request_target", Some("closed")));
243 assert_eq!(github_events("comment.created"), [("issue_comment", Some("created"))]);
244 assert_eq!(github_events("issue.labeled"), [("issues", Some("labeled"))]);
245 assert_eq!(github_events("pull.unlabeled")[0], ("pull_request", Some("unlabeled")));
246 assert_eq!(github_events("pull.milestoned")[1], ("pull_request_target", Some("milestoned")));
247 assert_eq!(github_events("pull.base_changed")[0], ("pull_request", Some("edited")));
248 assert_eq!(github_events("pull.reopened")[0], ("pull_request", Some("reopened")));
249 assert_eq!(github_events("pull.converted_to_draft")[1], ("pull_request_target", Some("converted_to_draft")));
250 assert_eq!(github_events("comment.edited"), [("issue_comment", Some("edited"))]);
251 assert_eq!(github_events("comment.deleted"), [("issue_comment", Some("deleted"))]);
252 assert_eq!(github_events("release.published"), [("release", Some("published"))]);
253 assert_eq!(github_events("release.prereleased"), [("release", Some("prereleased"))]);
254 assert_eq!(github_events("deployment_status.created"), [("deployment_status", Some("created"))]);
255 assert_eq!(github_events("deployment.created"), [("deployment", None)]);
256 // g1t.page's own deployment outcomes are the inbox's, not a GitHub event.
257 assert!(github_events("deployment.succeeded").is_empty());
258 assert!(github_events("session.appended").is_empty());
259 }
260
261 #[test]
262 fn contexts_and_variables_agree() {
263 let info = RunInfo {
264 repository: "acme/web".into(),
265 default_branch: "main".into(),
266 event_name: "push".into(),
267 git_ref: "refs/tags/v1.2.0".into(),
268 sha: "abc".into(),
269 run_number: 7,
270 run_attempt: 1,
271 server_url: "https://g1t.sh".into(),
272 ..RunInfo::default()
273 };
274 let github = info.context("build", "tok", None);
275 assert_eq!(github["ref_name"], "v1.2.0");
276 assert_eq!(github["ref_type"], "tag");
277 assert_eq!(github["repository_owner"], "acme");
278 assert_eq!(github["run_number"], "7");
279 let vars = info.variables("build");
280 assert_eq!(vars["GITHUB_REF_NAME"], "v1.2.0");
281 assert_eq!(vars["GITHUB_JOB"], "build");
282 assert_eq!(vars["RUNNER_OS"], "Linux");
283 }
284}
285
286#[cfg(test)]
287mod skip_tests {
288 use super::skips_ci;
289
290 #[test]
291 fn a_commit_can_skip_its_runs_as_on_github() {
292 for message in ["Fix typo [skip ci]", "[ci skip] docs", "Notes [no ci]", "x [skip actions]", "[Actions Skip]", "Tidy\n\nskip-checks: true", "Tidy\n\nSigned-off-by: A <a@b>\nskip-checks:true\n"] {
293 assert!(skips_ci(message), "{message:?}");
294 }
295 for message in ["Fix the skip ci flag", "skip-checks: true", "Tidy\n\nskip-checks: false", "[skip-ci]", "Tidy\n\nskip-checks: true\n\nMore"] {
296 assert!(!skips_ci(message), "{message:?}");
297 }
298 }
299}