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.

422 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 "work_item.opened" => vec![("work_item", Some("opened"))],
32 "work_item.updated" => vec![("work_item", Some("edited"))],
33 "work_item.closed" => vec![("work_item", Some("closed"))],
34 "work_item.reopened" => vec![("work_item", Some("reopened"))],
35 "work_item.assigned" => vec![("work_item", Some("assigned"))],
36 "work_item.labeled" => vec![("work_item", Some("labeled"))],
37 "work_item.unlabeled" => vec![("work_item", Some("unlabeled"))],
38 "work_item.milestoned" => vec![("work_item", Some("milestoned"))],
39 "work_item.demilestoned" => vec![("work_item", Some("demilestoned"))],
40 "work_item.planned" => vec![("work_item", Some("planned"))],
41 "work_item.linked" => vec![("work_item", Some("linked"))],
42 "work_item.unlinked" => vec![("work_item", Some("unlinked"))],
43 "work_item.landed" => vec![("work_item", Some("landed"))],
44 "comment.created" => vec![("work_item_comment", Some("created"))],
45 "comment.edited" => vec![("work_item_comment", Some("edited"))],
46 "comment.deleted" => vec![("work_item_comment", Some("deleted"))],
47 "release.created" => vec![("release", Some("created"))],
48 "release.published" => vec![("release", Some("published"))],
49 "release.released" => vec![("release", Some("released"))],
50 "release.prereleased" => vec![("release", Some("prereleased"))],
51 "release.edited" => vec![("release", Some("edited"))],
52 "release.unpublished" => vec![("release", Some("unpublished"))],
53 "release.deleted" => vec![("release", Some("deleted"))],
54 "deployment.created" => vec![("deployment", None)],
55 "deployment_status.created" => vec![("deployment_status", Some("created"))],
56 "review.completed" => vec![("pull_request_review", Some("submitted"))],
57 "workflow.completed" => vec![("workflow_run", Some("completed"))],
58 _ => Vec::new(),
59 }
60}
61
62/// What a run is about: enough to fill the `github` context.
63#[derive(Clone, Debug, Default, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct RunInfo {
66 /// `acme/web`.
67 pub repository: String,
68 pub repository_id: String,
69 pub default_branch: String,
70 pub event_name: String,
71 /// The webhook-shaped payload, `github.event`.
72 pub event: Value,
73 /// `refs/heads/main`, `refs/tags/v1`, `refs/pull/3/merge`.
74 pub git_ref: String,
75 pub sha: String,
76 /// For pull requests: the head and base branches.
77 pub head_ref: Option<String>,
78 pub base_ref: Option<String>,
79 pub actor: String,
80 pub actor_id: String,
81 pub triggering_actor: String,
82 pub run_id: String,
83 pub run_number: u64,
84 pub run_attempt: u64,
85 /// The workflow's name.
86 pub workflow: String,
87 /// `.g1t/workflows/ci.yml`.
88 pub workflow_path: String,
89 pub server_url: String,
90 pub api_url: String,
91}
92
93impl RunInfo {
94 pub fn ref_name(&self) -> String {
95 self.git_ref
96 .strip_prefix("refs/heads/")
97 .or_else(|| self.git_ref.strip_prefix("refs/tags/"))
98 .or_else(|| self.git_ref.strip_prefix("refs/"))
99 .unwrap_or(&self.git_ref)
100 .to_owned()
101 }
102
103 pub fn ref_type(&self) -> &'static str {
104 if self.git_ref.starts_with("refs/tags/") { "tag" } else { "branch" }
105 }
106
107 fn owner(&self) -> &str {
108 self.repository.split('/').next().unwrap_or_default()
109 }
110
111 /// The owner's id, as the event's `repository.owner.id` gives it, or
112 /// else the number for the owner's name ([`account_id`]).
113 fn owner_id(&self) -> String {
114 owner_id(&self.event).unwrap_or_else(|| match self.owner() {
115 "" => String::new(),
116 owner => account_id(owner).to_string(),
117 })
118 }
119
120 /// `github.repository_id`: the repository's number ([`github_id`]).
121 fn repository_number(&self) -> String {
122 id_text(&self.repository_id)
123 }
124
125 /// `github.actor_id`: the actor's number, as the event's `sender.id`
126 /// gives it ([`account_id`]).
127 fn actor_number(&self) -> String {
128 match self.actor.as_str() {
129 "" => id_text(&self.actor_id),
130 actor => account_id(actor).to_string(),
131 }
132 }
133
134 /// The `github` context for a job. `token` is `github.token` (and
135 /// `secrets.GITHUB_TOKEN`); `job` is the job's id.
136 pub fn context(&self, job: &str, token: &str, action: Option<&str>) -> Value {
137 json!({
138 "action": action.unwrap_or_default(),
139 "action_path": "",
140 "action_ref": "",
141 "action_repository": "",
142 "actor": self.actor,
143 "actor_id": self.actor_number(),
144 "api_url": self.api_url,
145 "base_ref": self.base_ref.clone().unwrap_or_default(),
146 "env": "",
147 "event": self.event,
148 "event_name": self.event_name,
149 "event_path": "/home/runner/_temp/event.json",
150 "graphql_url": "",
151 "head_ref": self.head_ref.clone().unwrap_or_default(),
152 "job": job,
153 "path": "",
154 "ref": self.git_ref,
155 "ref_name": self.ref_name(),
156 "ref_protected": self.ref_name() == self.default_branch,
157 "ref_type": self.ref_type(),
158 "repository": self.repository,
159 "repository_id": self.repository_number(),
160 "repository_owner": self.owner(),
161 "repository_owner_id": self.owner_id(),
162 "repositoryUrl": format!("{}/{}.git", self.server_url, self.repository),
163 "retention_days": 14,
164 "run_attempt": self.run_attempt.to_string(),
165 "run_id": self.run_id,
166 "run_number": self.run_number.to_string(),
167 "secret_source": "Actions",
168 "server_url": self.server_url,
169 "sha": self.sha,
170 "token": token,
171 "triggering_actor": self.triggering_actor,
172 "workflow": self.workflow,
173 "workflow_ref": format!("{}/{}@{}", self.repository, self.workflow_path, self.git_ref),
174 "workflow_sha": self.sha,
175 "workspace": WORKSPACE,
176 })
177 }
178
179 /// The `GITHUB_*` and `RUNNER_*` variables every step gets.
180 pub fn variables(&self, job: &str) -> Map<String, Value> {
181 let mut vars = Map::new();
182 let mut set = |key: &str, value: String| {
183 vars.insert(key.to_owned(), Value::String(value));
184 };
185 set("CI", "true".into());
186 set("GITHUB_ACTIONS", "true".into());
187 set("G1T", "true".into());
188 set("GITHUB_ACTOR", self.actor.clone());
189 set("GITHUB_ACTOR_ID", self.actor_number());
190 set("GITHUB_API_URL", self.api_url.clone());
191 set("GITHUB_BASE_REF", self.base_ref.clone().unwrap_or_default());
192 set("GITHUB_EVENT_NAME", self.event_name.clone());
193 set("GITHUB_EVENT_PATH", "/home/runner/_temp/event.json".into());
194 set("GITHUB_GRAPHQL_URL", String::new());
195 set("GITHUB_HEAD_REF", self.head_ref.clone().unwrap_or_default());
196 set("GITHUB_JOB", job.to_owned());
197 set("GITHUB_REF", self.git_ref.clone());
198 set("GITHUB_REF_NAME", self.ref_name());
199 set("GITHUB_REF_PROTECTED", (self.ref_name() == self.default_branch).to_string());
200 set("GITHUB_REF_TYPE", self.ref_type().into());
201 set("GITHUB_REPOSITORY", self.repository.clone());
202 set("GITHUB_REPOSITORY_ID", self.repository_number());
203 set("GITHUB_REPOSITORY_OWNER", self.owner().to_owned());
204 set("GITHUB_REPOSITORY_OWNER_ID", self.owner_id());
205 set("GITHUB_RETENTION_DAYS", "14".into());
206 set("GITHUB_RUN_ATTEMPT", self.run_attempt.to_string());
207 set("GITHUB_RUN_ID", self.run_id.clone());
208 set("GITHUB_RUN_NUMBER", self.run_number.to_string());
209 set("GITHUB_SERVER_URL", self.server_url.clone());
210 set("GITHUB_SHA", self.sha.clone());
211 set("GITHUB_TRIGGERING_ACTOR", self.triggering_actor.clone());
212 set("GITHUB_WORKFLOW", self.workflow.clone());
213 set("GITHUB_WORKFLOW_REF", format!("{}/{}@{}", self.repository, self.workflow_path, self.git_ref));
214 set("GITHUB_WORKFLOW_SHA", self.sha.clone());
215 set("GITHUB_WORKSPACE", WORKSPACE.into());
216 set("RUNNER_ARCH", "X64".into());
217 set("RUNNER_NAME", "g1t".into());
218 set("RUNNER_OS", "Linux".into());
219 set("RUNNER_TEMP", "/home/runner/_temp".into());
220 set("RUNNER_TOOL_CACHE", "/home/runner/_tool".into());
221 set("RUNNER_ENVIRONMENT", "github-hosted".into());
222 set("RUNNER_WORKSPACE", RUNNER_WORKSPACE.into());
223 vars
224 }
225}
226
227/// The whole number GitHub gives an object as its `id`, for a g1t id
228/// (`rep_01kpw0…`, `pr_01…`): the same number every time, positive, and
229/// below 2^53 so JavaScript reads it exactly. g1t's own id stays the
230/// object's `node_id`.
231pub fn github_id(id: &str) -> u64 {
232 // FNV-1a, 64 bits, cut to 53.
233 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
234 for byte in id.bytes() {
235 hash ^= u64::from(byte);
236 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
237 }
238 (hash & ((1 << 53) - 1)).max(1)
239}
240
241/// The number of a person or workspace, by their name: usernames and
242/// workspaces share one namespace, so `sender.id`, `user.id`,
243/// `repository.owner.id`, `github.actor_id` and
244/// `github.repository_owner_id` agree wherever the same name appears, and
245/// a personal workspace's repositories are owned by its person's number,
246/// as on GitHub. Case does not matter.
247pub fn account_id(login: &str) -> u64 {
248 github_id(&format!("account:{}", login.to_ascii_lowercase()))
249}
250
251/// An id as text of a number: one that is already a number as it is,
252/// a g1t id as its [`github_id`], nothing as nothing.
253fn id_text(id: &str) -> String {
254 if id.is_empty() || id.bytes().all(|b| b.is_ascii_digit()) {
255 id.to_owned()
256 } else {
257 github_id(id).to_string()
258 }
259}
260
261/// The owner's id in a webhook-shaped payload, `repository.owner.id`, a
262/// number or a string.
263pub fn owner_id(event: &Value) -> Option<String> {
264 match &event["repository"]["owner"]["id"] {
265 Value::String(id) => Some(id.clone()),
266 Value::Number(id) => Some(id.to_string()),
267 _ => None,
268 }
269}
270
271/// Where a job's repository is checked out, as on GitHub's runners.
272pub const WORKSPACE: &str = "/home/runner/work/repo";
273
274/// The folder the workspace is in, as GitHub's `RUNNER_WORKSPACE` is.
275pub const RUNNER_WORKSPACE: &str = "/home/runner/work";
276
277/// The `runner` context.
278/// Whether a head commit's `message` asks, as GitHub lets it, that the
279/// `push` and `pull_request` workflows it would start don't run: `[skip ci]`,
280/// `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]` anywhere in
281/// it, or a `skip-checks: true` trailer.
282pub fn skips_ci(message: &str) -> bool {
283 let lower = message.to_ascii_lowercase();
284 if ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"].iter().any(|marker| lower.contains(marker)) {
285 return true;
286 }
287 // Trailers are the last paragraph, after a blank line.
288 let trimmed = lower.trim_end();
289 let Some((_, trailers)) = trimmed.rsplit_once("\n\n") else { return false };
290 trailers.lines().any(|line| line.split_once(':').is_some_and(|(key, value)| key.trim() == "skip-checks" && value.trim() == "true"))
291}
292
293pub fn runner_context() -> Value {
294 json!({
295 "name": "g1t",
296 "os": "Linux",
297 "arch": "X64",
298 "temp": "/home/runner/_temp",
299 "tool_cache": "/home/runner/_tool",
300 "environment": "github-hosted",
301 "debug": "",
302 })
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn g1t_events_are_github_events() {
311 assert_eq!(github_events("git.push"), [("push", None)]);
312 assert_eq!(github_events("git.deleted"), [("delete", None)]);
313 assert_eq!(github_events("pull.labeled")[1], ("pull_request_target", Some("labeled")));
314 assert_eq!(github_events("pull.assigned")[0], ("pull_request", Some("assigned")));
315 assert_eq!(github_events("work_item.unlabeled"), [("work_item", Some("unlabeled"))]);
316 assert_eq!(github_events("work_item.assigned"), [("work_item", Some("assigned"))]);
317 assert_eq!(github_events("work_item.updated"), [("work_item", Some("edited"))]);
318 assert_eq!(github_events("work_item.linked"), [("work_item", Some("linked"))]);
319 assert_eq!(github_events("work_item.landed"), [("work_item", Some("landed"))]);
320 assert_eq!(github_events("work_item.planned"), [("work_item", Some("planned"))]);
321 assert!(github_events("issue.opened").is_empty());
322 assert_eq!(github_events("pull.updated")[0], ("pull_request", Some("synchronize")));
323 assert_eq!(github_events("pull.merged")[1], ("pull_request_target", Some("closed")));
324 assert_eq!(github_events("comment.created"), [("work_item_comment", Some("created"))]);
325 assert_eq!(github_events("work_item.labeled"), [("work_item", Some("labeled"))]);
326 assert_eq!(github_events("pull.unlabeled")[0], ("pull_request", Some("unlabeled")));
327 assert_eq!(github_events("pull.milestoned")[1], ("pull_request_target", Some("milestoned")));
328 assert_eq!(github_events("pull.base_changed")[0], ("pull_request", Some("edited")));
329 assert_eq!(github_events("pull.reopened")[0], ("pull_request", Some("reopened")));
330 assert_eq!(github_events("pull.converted_to_draft")[1], ("pull_request_target", Some("converted_to_draft")));
331 assert_eq!(github_events("comment.edited"), [("work_item_comment", Some("edited"))]);
332 assert_eq!(github_events("comment.deleted"), [("work_item_comment", Some("deleted"))]);
333 assert_eq!(github_events("release.published"), [("release", Some("published"))]);
334 assert_eq!(github_events("release.prereleased"), [("release", Some("prereleased"))]);
335 assert_eq!(github_events("deployment_status.created"), [("deployment_status", Some("created"))]);
336 assert_eq!(github_events("deployment.created"), [("deployment", None)]);
337 // g1t.page's own deployment outcomes are the inbox's, not a GitHub event.
338 assert!(github_events("deployment.succeeded").is_empty());
339 assert!(github_events("session.appended").is_empty());
340 }
341
342 #[test]
343 fn contexts_and_variables_agree() {
344 let info = RunInfo {
345 repository: "acme/web".into(),
346 default_branch: "main".into(),
347 event_name: "push".into(),
348 git_ref: "refs/tags/v1.2.0".into(),
349 sha: "abc".into(),
350 run_number: 7,
351 run_attempt: 1,
352 server_url: "https://g1t.sh".into(),
353 ..RunInfo::default()
354 };
355 let github = info.context("build", "tok", None);
356 assert_eq!(github["ref_name"], "v1.2.0");
357 assert_eq!(github["ref_type"], "tag");
358 assert_eq!(github["repository_owner"], "acme");
359 assert_eq!(github["run_number"], "7");
360 let vars = info.variables("build");
361 assert_eq!(vars["GITHUB_REF_NAME"], "v1.2.0");
362 assert_eq!(vars["GITHUB_JOB"], "build");
363 assert_eq!(vars["RUNNER_OS"], "Linux");
364 assert_eq!(vars["RUNNER_WORKSPACE"], "/home/runner/work");
365 assert_eq!(vars["GITHUB_REPOSITORY_OWNER_ID"], account_id("acme").to_string());
366 }
367
368 #[test]
369 fn ids_are_whole_numbers_as_on_github() {
370 let id = github_id("rep_01kpw0abcdefghjkmnpqrstvwx");
371 assert_eq!(id, github_id("rep_01kpw0abcdefghjkmnpqrstvwx"), "the same every time");
372 assert!(id > 0 && id < 1 << 53, "a safe integer in JavaScript");
373 assert_ne!(id, github_id("rep_01kpw0abcdefghjkmnpqrstvwy"));
374 assert_eq!(account_id("Ada"), account_id("ada"));
375 assert_ne!(account_id("ada"), github_id("ada"));
376 let info = RunInfo {
377 repository: "acme/web".into(),
378 repository_id: "rep_1".into(),
379 actor: "ada".into(),
380 actor_id: "usr_1".into(),
381 ..RunInfo::default()
382 };
383 let github = info.context("build", "", None);
384 assert_eq!(github["repository_id"], github_id("rep_1").to_string());
385 assert_eq!(github["actor_id"], account_id("ada").to_string());
386 assert_eq!(github["repository_owner_id"], account_id("acme").to_string());
387 let vars = info.variables("build");
388 assert_eq!(vars["GITHUB_REPOSITORY_ID"], github["repository_id"]);
389 assert_eq!(vars["GITHUB_ACTOR_ID"], github["actor_id"]);
390 assert!(vars["GITHUB_REPOSITORY_ID"].as_str().unwrap().bytes().all(|b| b.is_ascii_digit()));
391 // A number already is one.
392 assert_eq!(RunInfo { repository_id: "42".into(), ..RunInfo::default() }.context("", "", None)["repository_id"], "42");
393 }
394
395 #[test]
396 fn the_owners_id_comes_from_the_event() {
397 let info = RunInfo {
398 repository: "acme/web".into(),
399 event: json!({ "repository": { "owner": { "id": 42 } } }),
400 ..RunInfo::default()
401 };
402 assert_eq!(info.variables("build")["GITHUB_REPOSITORY_OWNER_ID"], "42");
403 assert_eq!(info.context("build", "", None)["repository_owner_id"], "42");
404 assert_eq!(owner_id(&json!({ "repository": { "owner": { "id": "ws_1" } } })).as_deref(), Some("ws_1"));
405 assert_eq!(owner_id(&json!({})), None);
406 }
407}
408
409#[cfg(test)]
410mod skip_tests {
411 use super::skips_ci;
412
413 #[test]
414 fn a_commit_can_skip_its_runs_as_on_github() {
415 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"] {
416 assert!(skips_ci(message), "{message:?}");
417 }
418 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"] {
419 assert!(!skips_ci(message), "{message:?}");
420 }
421 }
422}