| 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 | |
| 4 | use serde::{Deserialize, Serialize}; |
| 5 | use 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`. |
| 12 | pub 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")] |
| 61 | pub 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 | |
| 89 | impl 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 owner's id, as the event's `repository.owner.id` gives it. |
| 108 | fn owner_id(&self) -> String { |
| 109 | owner_id(&self.event).unwrap_or_default() |
| 110 | } |
| 111 | |
| 112 | /// The `github` context for a job. `token` is `github.token` (and |
| 113 | /// `secrets.GITHUB_TOKEN`); `job` is the job's id. |
| 114 | pub fn context(&self, job: &str, token: &str, action: Option<&str>) -> Value { |
| 115 | json!({ |
| 116 | "action": action.unwrap_or_default(), |
| 117 | "action_path": "", |
| 118 | "action_ref": "", |
| 119 | "action_repository": "", |
| 120 | "actor": self.actor, |
| 121 | "actor_id": self.actor_id, |
| 122 | "api_url": self.api_url, |
| 123 | "base_ref": self.base_ref.clone().unwrap_or_default(), |
| 124 | "env": "", |
| 125 | "event": self.event, |
| 126 | "event_name": self.event_name, |
| 127 | "event_path": "/home/runner/_temp/event.json", |
| 128 | "graphql_url": "", |
| 129 | "head_ref": self.head_ref.clone().unwrap_or_default(), |
| 130 | "job": job, |
| 131 | "path": "", |
| 132 | "ref": self.git_ref, |
| 133 | "ref_name": self.ref_name(), |
| 134 | "ref_protected": self.ref_name() == self.default_branch, |
| 135 | "ref_type": self.ref_type(), |
| 136 | "repository": self.repository, |
| 137 | "repository_id": self.repository_id, |
| 138 | "repository_owner": self.owner(), |
| 139 | "repository_owner_id": self.owner_id(), |
| 140 | "repositoryUrl": format!("{}/{}.git", self.server_url, self.repository), |
| 141 | "retention_days": 14, |
| 142 | "run_attempt": self.run_attempt.to_string(), |
| 143 | "run_id": self.run_id, |
| 144 | "run_number": self.run_number.to_string(), |
| 145 | "secret_source": "Actions", |
| 146 | "server_url": self.server_url, |
| 147 | "sha": self.sha, |
| 148 | "token": token, |
| 149 | "triggering_actor": self.triggering_actor, |
| 150 | "workflow": self.workflow, |
| 151 | "workflow_ref": format!("{}/{}@{}", self.repository, self.workflow_path, self.git_ref), |
| 152 | "workflow_sha": self.sha, |
| 153 | "workspace": WORKSPACE, |
| 154 | }) |
| 155 | } |
| 156 | |
| 157 | /// The `GITHUB_*` and `RUNNER_*` variables every step gets. |
| 158 | pub fn variables(&self, job: &str) -> Map<String, Value> { |
| 159 | let mut vars = Map::new(); |
| 160 | let mut set = |key: &str, value: String| { |
| 161 | vars.insert(key.to_owned(), Value::String(value)); |
| 162 | }; |
| 163 | set("CI", "true".into()); |
| 164 | set("GITHUB_ACTIONS", "true".into()); |
| 165 | set("G1T", "true".into()); |
| 166 | set("GITHUB_ACTOR", self.actor.clone()); |
| 167 | set("GITHUB_ACTOR_ID", self.actor_id.clone()); |
| 168 | set("GITHUB_API_URL", self.api_url.clone()); |
| 169 | set("GITHUB_BASE_REF", self.base_ref.clone().unwrap_or_default()); |
| 170 | set("GITHUB_EVENT_NAME", self.event_name.clone()); |
| 171 | set("GITHUB_EVENT_PATH", "/home/runner/_temp/event.json".into()); |
| 172 | set("GITHUB_GRAPHQL_URL", String::new()); |
| 173 | set("GITHUB_HEAD_REF", self.head_ref.clone().unwrap_or_default()); |
| 174 | set("GITHUB_JOB", job.to_owned()); |
| 175 | set("GITHUB_REF", self.git_ref.clone()); |
| 176 | set("GITHUB_REF_NAME", self.ref_name()); |
| 177 | set("GITHUB_REF_PROTECTED", (self.ref_name() == self.default_branch).to_string()); |
| 178 | set("GITHUB_REF_TYPE", self.ref_type().into()); |
| 179 | set("GITHUB_REPOSITORY", self.repository.clone()); |
| 180 | set("GITHUB_REPOSITORY_ID", self.repository_id.clone()); |
| 181 | set("GITHUB_REPOSITORY_OWNER", self.owner().to_owned()); |
| 182 | set("GITHUB_REPOSITORY_OWNER_ID", self.owner_id()); |
| 183 | set("GITHUB_RETENTION_DAYS", "14".into()); |
| 184 | set("GITHUB_RUN_ATTEMPT", self.run_attempt.to_string()); |
| 185 | set("GITHUB_RUN_ID", self.run_id.clone()); |
| 186 | set("GITHUB_RUN_NUMBER", self.run_number.to_string()); |
| 187 | set("GITHUB_SERVER_URL", self.server_url.clone()); |
| 188 | set("GITHUB_SHA", self.sha.clone()); |
| 189 | set("GITHUB_TRIGGERING_ACTOR", self.triggering_actor.clone()); |
| 190 | set("GITHUB_WORKFLOW", self.workflow.clone()); |
| 191 | set("GITHUB_WORKFLOW_REF", format!("{}/{}@{}", self.repository, self.workflow_path, self.git_ref)); |
| 192 | set("GITHUB_WORKFLOW_SHA", self.sha.clone()); |
| 193 | set("GITHUB_WORKSPACE", WORKSPACE.into()); |
| 194 | set("RUNNER_ARCH", "X64".into()); |
| 195 | set("RUNNER_NAME", "g1t".into()); |
| 196 | set("RUNNER_OS", "Linux".into()); |
| 197 | set("RUNNER_TEMP", "/home/runner/_temp".into()); |
| 198 | set("RUNNER_TOOL_CACHE", "/home/runner/_tool".into()); |
| 199 | set("RUNNER_ENVIRONMENT", "github-hosted".into()); |
| 200 | set("RUNNER_WORKSPACE", RUNNER_WORKSPACE.into()); |
| 201 | vars |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | /// The owner's id in a webhook-shaped payload, `repository.owner.id`, a |
| 206 | /// number or a string. |
| 207 | pub fn owner_id(event: &Value) -> Option<String> { |
| 208 | match &event["repository"]["owner"]["id"] { |
| 209 | Value::String(id) => Some(id.clone()), |
| 210 | Value::Number(id) => Some(id.to_string()), |
| 211 | _ => None, |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | /// Where a job's repository is checked out, as on GitHub's runners. |
| 216 | pub const WORKSPACE: &str = "/home/runner/work/repo"; |
| 217 | |
| 218 | /// The folder the workspace is in, as GitHub's `RUNNER_WORKSPACE` is. |
| 219 | pub const RUNNER_WORKSPACE: &str = "/home/runner/work"; |
| 220 | |
| 221 | /// The `runner` context. |
| 222 | /// Whether a head commit's `message` asks, as GitHub lets it, that the |
| 223 | /// `push` and `pull_request` workflows it would start don't run: `[skip ci]`, |
| 224 | /// `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]` anywhere in |
| 225 | /// it, or a `skip-checks: true` trailer. |
| 226 | pub fn skips_ci(message: &str) -> bool { |
| 227 | let lower = message.to_ascii_lowercase(); |
| 228 | if ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"].iter().any(|marker| lower.contains(marker)) { |
| 229 | return true; |
| 230 | } |
| 231 | // Trailers are the last paragraph, after a blank line. |
| 232 | let trimmed = lower.trim_end(); |
| 233 | let Some((_, trailers)) = trimmed.rsplit_once("\n\n") else { return false }; |
| 234 | trailers.lines().any(|line| line.split_once(':').is_some_and(|(key, value)| key.trim() == "skip-checks" && value.trim() == "true")) |
| 235 | } |
| 236 | |
| 237 | pub fn runner_context() -> Value { |
| 238 | json!({ |
| 239 | "name": "g1t", |
| 240 | "os": "Linux", |
| 241 | "arch": "X64", |
| 242 | "temp": "/home/runner/_temp", |
| 243 | "tool_cache": "/home/runner/_tool", |
| 244 | "environment": "github-hosted", |
| 245 | "debug": "", |
| 246 | }) |
| 247 | } |
| 248 | |
| 249 | #[cfg(test)] |
| 250 | mod tests { |
| 251 | use super::*; |
| 252 | |
| 253 | #[test] |
| 254 | fn g1t_events_are_github_events() { |
| 255 | assert_eq!(github_events("git.push"), [("push", None)]); |
| 256 | assert_eq!(github_events("git.deleted"), [("delete", None)]); |
| 257 | assert_eq!(github_events("pull.labeled")[1], ("pull_request_target", Some("labeled"))); |
| 258 | assert_eq!(github_events("pull.assigned")[0], ("pull_request", Some("assigned"))); |
| 259 | assert_eq!(github_events("issue.unlabeled"), [("issues", Some("unlabeled"))]); |
| 260 | assert_eq!(github_events("issue.assigned"), [("issues", Some("assigned"))]); |
| 261 | assert_eq!(github_events("pull.updated")[0], ("pull_request", Some("synchronize"))); |
| 262 | assert_eq!(github_events("pull.merged")[1], ("pull_request_target", Some("closed"))); |
| 263 | assert_eq!(github_events("comment.created"), [("issue_comment", Some("created"))]); |
| 264 | assert_eq!(github_events("issue.labeled"), [("issues", Some("labeled"))]); |
| 265 | assert_eq!(github_events("pull.unlabeled")[0], ("pull_request", Some("unlabeled"))); |
| 266 | assert_eq!(github_events("pull.milestoned")[1], ("pull_request_target", Some("milestoned"))); |
| 267 | assert_eq!(github_events("pull.base_changed")[0], ("pull_request", Some("edited"))); |
| 268 | assert_eq!(github_events("pull.reopened")[0], ("pull_request", Some("reopened"))); |
| 269 | assert_eq!(github_events("pull.converted_to_draft")[1], ("pull_request_target", Some("converted_to_draft"))); |
| 270 | assert_eq!(github_events("comment.edited"), [("issue_comment", Some("edited"))]); |
| 271 | assert_eq!(github_events("comment.deleted"), [("issue_comment", Some("deleted"))]); |
| 272 | assert_eq!(github_events("release.published"), [("release", Some("published"))]); |
| 273 | assert_eq!(github_events("release.prereleased"), [("release", Some("prereleased"))]); |
| 274 | assert_eq!(github_events("deployment_status.created"), [("deployment_status", Some("created"))]); |
| 275 | assert_eq!(github_events("deployment.created"), [("deployment", None)]); |
| 276 | // g1t.page's own deployment outcomes are the inbox's, not a GitHub event. |
| 277 | assert!(github_events("deployment.succeeded").is_empty()); |
| 278 | assert!(github_events("session.appended").is_empty()); |
| 279 | } |
| 280 | |
| 281 | #[test] |
| 282 | fn contexts_and_variables_agree() { |
| 283 | let info = RunInfo { |
| 284 | repository: "acme/web".into(), |
| 285 | default_branch: "main".into(), |
| 286 | event_name: "push".into(), |
| 287 | git_ref: "refs/tags/v1.2.0".into(), |
| 288 | sha: "abc".into(), |
| 289 | run_number: 7, |
| 290 | run_attempt: 1, |
| 291 | server_url: "https://g1t.sh".into(), |
| 292 | ..RunInfo::default() |
| 293 | }; |
| 294 | let github = info.context("build", "tok", None); |
| 295 | assert_eq!(github["ref_name"], "v1.2.0"); |
| 296 | assert_eq!(github["ref_type"], "tag"); |
| 297 | assert_eq!(github["repository_owner"], "acme"); |
| 298 | assert_eq!(github["run_number"], "7"); |
| 299 | let vars = info.variables("build"); |
| 300 | assert_eq!(vars["GITHUB_REF_NAME"], "v1.2.0"); |
| 301 | assert_eq!(vars["GITHUB_JOB"], "build"); |
| 302 | assert_eq!(vars["RUNNER_OS"], "Linux"); |
| 303 | assert_eq!(vars["RUNNER_WORKSPACE"], "/home/runner/work"); |
| 304 | assert_eq!(vars["GITHUB_REPOSITORY_OWNER_ID"], ""); |
| 305 | } |
| 306 | |
| 307 | #[test] |
| 308 | fn the_owners_id_comes_from_the_event() { |
| 309 | let info = RunInfo { |
| 310 | repository: "acme/web".into(), |
| 311 | event: json!({ "repository": { "owner": { "id": 42 } } }), |
| 312 | ..RunInfo::default() |
| 313 | }; |
| 314 | assert_eq!(info.variables("build")["GITHUB_REPOSITORY_OWNER_ID"], "42"); |
| 315 | assert_eq!(info.context("build", "", None)["repository_owner_id"], "42"); |
| 316 | assert_eq!(owner_id(&json!({ "repository": { "owner": { "id": "ws_1" } } })).as_deref(), Some("ws_1")); |
| 317 | assert_eq!(owner_id(&json!({})), None); |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | #[cfg(test)] |
| 322 | mod skip_tests { |
| 323 | use super::skips_ci; |
| 324 | |
| 325 | #[test] |
| 326 | fn a_commit_can_skip_its_runs_as_on_github() { |
| 327 | 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"] { |
| 328 | assert!(skips_ci(message), "{message:?}"); |
| 329 | } |
| 330 | 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"] { |
| 331 | assert!(!skips_ci(message), "{message:?}"); |
| 332 | } |
| 333 | } |
| 334 | } |