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.
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1 | //! Using another repository's actions and reusable workflows: |
| 2 | //! `uses: owner/repo@ref`, `owner/repo/path@ref` and | |
| 3 | //! `jobs.<id>.uses: owner/repo/.g1t/workflows/build.yml@ref`. | |
| 4 | //! | |
| 5 | //! The repository is looked for on g1t first, as the calling repository's | |
| 6 | //! workspace sees it. When g1t has it, the calling repository may use it if | |
| 7 | //! it is the same repository, if it is public, or if it is private, in the | |
| 8 | //! same workspace, allows it (Settings, Actions, Access: `organization`) | |
| 9 | //! and the caller is private too (a public repository's logs would show a | |
| 10 | //! private one's code). When g1t does not have it (or the workspace cannot | |
| 11 | //! see it), the action comes from GitHub, as before, and the reusable | |
| 12 | //! workflow from a public repository there. | |
| 13 | ||
| 14 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 15 | use g1t_contracts::{FailureCode, Outcome, User}; | |
| 16 | use serde_json::{Value, json}; | |
| 17 | use worker::Result; | |
| 18 | ||
| 19 | use crate::{Actions, SITE, fail}; | |
| 20 | ||
| 21 | /// What `uses:` names in another repository. | |
| 22 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 23 | pub(crate) struct UsesRef { | |
| 24 | pub(crate) owner: String, | |
| 25 | pub(crate) repo: String, | |
| 26 | /// Inside the repository: an action's folder, or a workflow's file. | |
| 27 | /// Empty for an action at its root. | |
| 28 | pub(crate) path: String, | |
| 29 | pub(crate) git_ref: String, | |
| 30 | } | |
| 31 | ||
| 32 | impl UsesRef { | |
| 33 | pub(crate) fn full_name(&self) -> String { | |
| 34 | format!("{}/{}", self.owner, self.repo) | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | /// `owner/repo[/path]@ref`, if `uses` is that. Local (`./…`) and | |
| 39 | /// `docker://` ones are not. | |
| 40 | pub(crate) fn parse_uses(uses: &str) -> Option<UsesRef> { | |
| 41 | let uses = uses.trim(); | |
| 42 | if uses.starts_with("./") || uses.starts_with("docker://") { | |
| 43 | return None; | |
| 44 | } | |
| 45 | let (name, git_ref) = uses.split_once('@')?; | |
| 46 | let mut parts = name.splitn(3, '/'); | |
| 47 | let (owner, repo) = (parts.next()?, parts.next()?); | |
| 48 | let path = parts.next().unwrap_or_default().trim_matches('/'); | |
| 49 | let fine = |part: &str| !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && part != ".."; | |
| 50 | if !fine(owner) || !fine(repo) || git_ref.trim().is_empty() || path.split('/').any(|part| part == "..") { | |
| 51 | return None; | |
| 52 | } | |
| 53 | Some(UsesRef { owner: owner.to_owned(), repo: repo.to_owned(), path: path.to_owned(), git_ref: git_ref.trim().to_owned() }) | |
| 54 | } | |
| 55 | ||
| 56 | /// Whether `caller`'s workflows may use `target`'s actions and reusable | |
| 57 | /// workflows, given `target`'s access level (`none` or `organization`). | |
| 58 | /// Err says why not. | |
| 59 | pub(crate) fn may_use(caller: &Repo, target: &Repo, access_level: &str) -> std::result::Result<(), String> { | |
| 60 | if caller.id == target.id || !target.is_private { | |
| 61 | return Ok(()); | |
| 62 | } | |
| 63 | let name = format!("{}/{}", target.namespace, target.name); | |
| 64 | if !caller.namespace.eq_ignore_ascii_case(&target.namespace) { | |
| 65 | return Err(format!("{name} is private, and only repositories in {} may use its actions and workflows.", target.namespace)); | |
| 66 | } | |
| 67 | if access_level != "organization" { | |
| 68 | return Err(format!( | |
| 69 | "{name} is private and does not let other repositories use its actions and workflows. An admin of {name} can allow it under Settings, Actions, Access." | |
| 70 | )); | |
| 71 | } | |
| 72 | if !caller.is_private { | |
| 73 | return Err(format!("{name} is private, and a public repository's workflows cannot use a private repository's actions or workflows.")); | |
| 74 | } | |
| 75 | Ok(()) | |
| 76 | } | |
| 77 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 78 | /// Whether `target` on g1t answers for its `owner/repo` name even when |
| 79 | /// GitHub has a repository of the same name: when it is in the caller's own | |
| 80 | /// workspace, or is one of g1t's own (`g1t` is a name nobody can register). | |
| 81 | /// Any other workspace's repository answers only for names GitHub doesn't | |
| 82 | /// publish. | |
| 83 | pub(crate) fn answers_for_its_name(caller: &Repo, target: &Repo) -> bool { | |
| 84 | caller.namespace.eq_ignore_ascii_case(&target.namespace) || target.namespace.eq_ignore_ascii_case("g1t") | |
| 85 | } | |
| 86 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 87 | /// Where another repository's action or workflow comes from. |
| 88 | pub(crate) enum Found { | |
| 89 | /// g1t has it, and the caller may use it. | |
| 90 | G1t(Repo), | |
| 91 | /// g1t does not have it (that the caller's workspace can see): GitHub. | |
| 92 | GitHub, | |
| 93 | } | |
| 94 | ||
| 95 | impl Actions { | |
| 96 | /// Looks for `owner/repo` on g1t, as `caller`'s workspace (`ws`) sees | |
| 97 | /// it, and checks the caller may use it. | |
| 98 | pub(crate) async fn find_used(&self, caller: &Repo, ws: &User, used: &UsesRef) -> Result<Outcome<Found>> { | |
| 99 | let path = RepoPath { namespace: used.owner.clone(), name: used.repo.clone() }; | |
| 100 | let Some(target) = self.visible_repo(&path, &Some(ws.clone())).await? else { | |
| 101 | return Ok(Outcome::Ok(Found::GitHub)); | |
| 102 | }; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 103 | // A name GitHub publishes means GitHub's repository, unless the |
| 104 | // repository on g1t is the caller's own workspace's or g1t's: anyone | |
| 105 | // can make a workspace called `actions`, and must not get to answer | |
| 106 | // for `actions/setup-node`. | |
| 107 | if !answers_for_its_name(caller, &target) && self.github_publishes(&used.full_name()).await? != Some(false) { | |
| 108 | return Ok(Outcome::Ok(Found::GitHub)); | |
| 109 | } | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 110 | let level = if target.is_private && target.id != caller.id { self.access_level_of(&target.id).await? } else { "none" }; |
| 111 | Ok(match may_use(caller, &target, level) { | |
| 112 | Ok(()) => Outcome::Ok(Found::G1t(target)), | |
| 113 | Err(why) => fail(FailureCode::Forbidden, why), | |
| 114 | }) | |
| 115 | } | |
| 116 | ||
| 117 | /// `job_action`: a running job asks where to fetch an action from, by | |
| 118 | /// `report.repository` (`owner/repo`) and `report.ref`. Answers | |
| 119 | /// `{"source": "g1t", "url", "ref", "token"}` (a read-only token for | |
| 120 | /// that repository, ending with the job, when it is private), | |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 121 | /// `{"source": "github", "sha", "archive", "note"}` (the commit its ref |
| 122 | /// names, and whether g1t keeps its archive; see registry.rs), or a | |
| 123 | /// refusal saying why it may not be used. | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 124 | pub async fn job_action(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<Outcome<Value>> { |
| 125 | let job = crate::check!(self.job_for_token(&a).await?); | |
| 126 | let Some(run) = self.run_row(&job.run_id).await? else { | |
| 127 | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 128 | }; | |
| 129 | let repository = a.report["repository"].as_str().unwrap_or_default(); | |
| 130 | let git_ref = a.report["ref"].as_str().unwrap_or_default(); | |
| 131 | let Some(used) = parse_uses(&format!("{repository}@{git_ref}")) else { | |
| 132 | return Ok(fail(FailureCode::Invalid, "Name the action's repository as owner/repo, and its ref.")); | |
| 133 | }; | |
| 134 | let Some((caller, ws)) = self.repo_by_id(&run.repo_id).await? else { | |
| 135 | return Ok(fail(FailureCode::NotFound, "The repository is gone.")); | |
| 136 | }; | |
| 137 | let target = match crate::check!(self.find_used(&caller, &ws, &used).await?) { | |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 138 | Found::GitHub => return Ok(Outcome::Ok(self.github_action(&used).await?)), |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 139 | Found::G1t(target) => target, |
| 140 | }; | |
| 141 | let full_name = format!("{}/{}", target.namespace, target.name); | |
| 142 | // A private repository is read with a token of its own: read-only, | |
| 143 | // for that repository alone, ending with the job. | |
| 144 | let token = if target.is_private { | |
| 145 | let created: g1t_contracts::identity::CreatedAccessToken = g1t_kit::call( | |
| 146 | &self.identity, | |
| 147 | "create_job_token", | |
| 148 | &g1t_contracts::identity::CreateJobTokenArgs { | |
| 149 | workspace: ws.clone(), | |
| 150 | repo: RepoPath { namespace: target.namespace.clone(), name: target.name.clone() }, | |
| 151 | run_id: run.id.clone(), | |
| 152 | job_id: job.id.clone(), | |
| 153 | name: format!("Action {full_name} for {} run {}", run.repo, run.number), | |
| 154 | ttl_seconds: u64::from(job.timeout_minutes) * 60 + 600, | |
| 155 | scopes: vec!["repo:read".to_owned(), "code:read".to_owned()], | |
| 156 | pull_requests: false, | |
| 157 | }, | |
| 158 | ) | |
| 159 | .await?; | |
| 160 | Value::String(created.token) | |
| 161 | } else { | |
| 162 | Value::Null | |
| 163 | }; | |
| 164 | Ok(Outcome::Ok(json!({ | |
| 165 | "source": "g1t", | |
| 166 | "repository": full_name, | |
| 167 | "url": format!("{SITE}/{full_name}.git"), | |
| 168 | "ref": used.git_ref, | |
| 169 | "token": token, | |
| 170 | }))) | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | /// Whether `path` is a workflow file: `.g1t/workflows/…` or | |
| 175 | /// `.github/workflows/…`, ending `.yml` or `.yaml`. | |
| 176 | pub(crate) fn is_workflow_path(path: &str) -> bool { | |
| 177 | (path.starts_with(".g1t/workflows/") || path.starts_with(".github/workflows/")) && (path.ends_with(".yml") || path.ends_with(".yaml")) | |
| 178 | } | |
| 179 | ||
| 180 | /// The paths to try for a workflow file: as written, and for `.github/…` | |
| 181 | /// also `.g1t/…`, where a repository moved to g1t keeps it. | |
| 182 | fn candidates(path: &str) -> Vec<String> { | |
| 183 | let mut paths = vec![path.to_owned()]; | |
| 184 | if let Some(rest) = path.strip_prefix(".github/") { | |
| 185 | paths.push(format!(".g1t/{rest}")); | |
| 186 | } | |
| 187 | paths | |
| 188 | } | |
| 189 | ||
| 190 | /// What a job's `secrets:` passes to the workflow it calls, kept with the | |
| 191 | /// called jobs until they start, when it is read with the secrets | |
| 192 | /// themselves (`resolve_secrets`); no secret is stored. `inherit` passes | |
| 193 | /// all of the caller's; a mapping passes each name's expression, read with | |
| 194 | /// the caller's `secrets` and the contexts it had (`needs`, `inputs`, | |
| 195 | /// `matrix`); nothing passes none. `outer` is the caller's own, when the | |
| 196 | /// caller is itself a called workflow's job. | |
| 197 | pub(crate) fn secrets_plan(job_raw: &Value, contexts: &serde_json::Map<String, Value>, outer: Option<Value>) -> Value { | |
| 198 | let mut plan = match job_raw.get("secrets") { | |
| 199 | Some(Value::String(text)) if text.trim() == "inherit" => json!({ "inherit": true }), | |
| 200 | Some(Value::Object(map)) => json!({ | |
| 201 | "map": map, | |
| 202 | "scope": { | |
| 203 | "needs": contexts.get("needs").cloned().unwrap_or_else(|| json!({})), | |
| 204 | "inputs": contexts.get("inputs").cloned().unwrap_or_else(|| json!({})), | |
| 205 | "matrix": contexts.get("matrix").cloned().unwrap_or_else(|| json!({})), | |
| 206 | }, | |
| 207 | }), | |
| 208 | _ => json!({ "map": {} }), | |
| 209 | }; | |
| 210 | if let Some(outer) = outer.filter(Value::is_object) { | |
| 211 | plan["outer"] = outer; | |
| 212 | } | |
| 213 | plan | |
| 214 | } | |
| 215 | ||
| 216 | /// The secrets a called workflow says are required | |
| 217 | /// (`on.workflow_call.secrets.<name>.required`) that `plan` does not pass. | |
| 218 | /// `inherit` passes whatever the caller has, so it is not checked here. | |
| 219 | pub(crate) fn missing_secrets(called_raw: &Value, plan: &Value) -> Vec<String> { | |
| 220 | if plan["inherit"] == json!(true) { | |
| 221 | return Vec::new(); | |
| 222 | } | |
| 223 | let on = called_raw.get("on").or_else(|| called_raw.get("true")).cloned().unwrap_or(Value::Null); | |
| 224 | let Some(Value::Object(declared)) = on.get("workflow_call").and_then(|call| call.get("secrets")).cloned() else { | |
| 225 | return Vec::new(); | |
| 226 | }; | |
| 227 | let passed = plan["map"].as_object().cloned().unwrap_or_default(); | |
| 228 | declared | |
| 229 | .iter() | |
| 230 | .filter(|(_, spec)| spec.get("required").and_then(Value::as_bool) == Some(true)) | |
| 231 | .filter(|(name, _)| !passed.keys().any(|key| key.eq_ignore_ascii_case(name))) | |
| 232 | .map(|(name, _)| name.clone()) | |
| 233 | .collect() | |
| 234 | } | |
| 235 | ||
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 236 | /// The secrets `plan` passes by name that the called workflow does not |
| 237 | /// declare under `on.workflow_call.secrets`, which GitHub refuses. | |
| 238 | pub(crate) fn undeclared_secrets(called_raw: &Value, plan: &Value) -> Vec<String> { | |
| 239 | let on = called_raw.get("on").or_else(|| called_raw.get("true")).cloned().unwrap_or(Value::Null); | |
| 240 | let declared = on.get("workflow_call").and_then(|call| call.get("secrets")).and_then(Value::as_object).cloned().unwrap_or_default(); | |
| 241 | plan["map"] | |
| 242 | .as_object() | |
| 243 | .into_iter() | |
| 244 | .flatten() | |
| 245 | .map(|(name, _)| name) | |
| 246 | .filter(|name| !declared.keys().any(|known| known.eq_ignore_ascii_case(name))) | |
| 247 | .cloned() | |
| 248 | .collect() | |
| 249 | } | |
| 250 | ||
| 251 | /// The inputs a called workflow gets, as GitHub reads them: `given` (the | |
| 252 | /// caller's `with:`, its expressions read) checked against `declared` | |
| 253 | /// (`on.workflow_call.inputs` of `file`). A name it does not declare is | |
| 254 | /// refused, and so is a value that is not of the input's `type`: a | |
| 255 | /// `boolean` takes `true` or `false`, a `number` a number (an output is | |
| 256 | /// text, so `fromJSON()` it), and a `string` text, a number or a boolean | |
| 257 | /// becoming its text. An input not given takes its `default`, else, unless | |
| 258 | /// it is `required`, `false`, `0` or `''` by its type. | |
| 259 | pub(crate) fn call_inputs( | |
| 260 | file: &str, | |
| 261 | declared: &serde_json::Map<String, Value>, | |
| 262 | given: &serde_json::Map<String, Value>, | |
| 263 | ) -> std::result::Result<serde_json::Map<String, Value>, String> { | |
| 264 | if let Some(name) = given.keys().find(|name| !declared.keys().any(|known| known.eq_ignore_ascii_case(name))) { | |
| 265 | return Err(format!("Invalid input: `{name}` is not defined in `{file}`.")); | |
| 266 | } | |
| 267 | let mut inputs = serde_json::Map::new(); | |
| 268 | for (name, spec) in declared { | |
| 269 | let kind = spec.get("type").and_then(Value::as_str).unwrap_or_default().trim().to_ascii_lowercase(); | |
| 270 | let passed = given.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value).filter(|value| !value.is_null()); | |
| 271 | let value = match (passed, spec.get("default").filter(|value| !value.is_null())) { | |
| 272 | (Some(value), _) => typed(&kind, value).ok_or_else(|| { | |
| 273 | let shown = match value { | |
| 274 | Value::String(text) => format!("'{text}'"), | |
| 275 | other => other.to_string(), | |
| 276 | }; | |
| 277 | let hint = if value.is_string() { " An output is text: pass `${{ fromJSON(...) }}` for its value." } else { "" }; | |
| 278 | format!("The input `{name}` of `{file}` is a {kind}, and `with` gives it {shown}.{hint}") | |
| 279 | })?, | |
| 280 | // A default as written, a `'true'` for a boolean taken as true. | |
| 281 | (None, Some(default)) => typed(&kind, default).or_else(|| loose(&kind, default)).unwrap_or_else(|| default.clone()), | |
| 282 | (None, None) if spec.get("required").and_then(Value::as_bool) == Some(true) => { | |
| 283 | return Err(format!("`{file}` needs the input `{name}`.")); | |
| 284 | } | |
| 285 | (None, None) => match kind.as_str() { | |
| 286 | "boolean" => Value::Bool(false), | |
| 287 | "number" => json!(0), | |
| 288 | "string" => Value::String(String::new()), | |
| 289 | _ => Value::Null, | |
| 290 | }, | |
| 291 | }; | |
| 292 | inputs.insert(name.clone(), value); | |
| 293 | } | |
| 294 | Ok(inputs) | |
| 295 | } | |
| 296 | ||
| 297 | /// `value` as an input of `kind`, if it is one. | |
| 298 | fn typed(kind: &str, value: &Value) -> Option<Value> { | |
| 299 | match (kind, value) { | |
| 300 | ("boolean", Value::Bool(_)) | ("number", Value::Number(_)) | ("string", Value::String(_)) => Some(value.clone()), | |
| 301 | ("string", Value::Bool(_) | Value::Number(_)) => Some(Value::String(g1t_actions::expr::to_text(value))), | |
| 302 | ("boolean" | "number" | "string", _) => None, | |
| 303 | _ => Some(value.clone()), | |
| 304 | } | |
| 305 | } | |
| 306 | ||
| 307 | /// A default written as text for a boolean or a number. | |
| 308 | fn loose(kind: &str, value: &Value) -> Option<Value> { | |
| 309 | let text = value.as_str()?.trim(); | |
| 310 | match kind { | |
| 311 | "boolean" if text.eq_ignore_ascii_case("true") => Some(Value::Bool(true)), | |
| 312 | "boolean" if text.eq_ignore_ascii_case("false") => Some(Value::Bool(false)), | |
| 313 | "number" => text.parse::<f64>().ok().and_then(serde_json::Number::from_f64).map(Value::Number), | |
| 314 | _ => None, | |
| 315 | } | |
| 316 | } | |
| 317 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 318 | /// The `secrets` a called workflow's job gets, by `plan` (`secrets_plan`), |
| 319 | /// from `base` (the repository's secrets, as the top caller has them), | |
| 320 | /// with `github` and `vars` for the expressions. The job's token is added | |
| 321 | /// by the caller of this, as every job's is. | |
| 322 | pub(crate) fn resolve_secrets( | |
| 323 | plan: &Value, | |
| 324 | base: &serde_json::Map<String, Value>, | |
| 325 | github: &Value, | |
| 326 | vars: &serde_json::Map<String, Value>, | |
| 327 | ) -> serde_json::Map<String, Value> { | |
| 328 | let outer = match plan.get("outer").filter(|outer| outer.is_object()) { | |
| 329 | Some(outer) => resolve_secrets(outer, base, github, vars), | |
| 330 | None => base.clone(), | |
| 331 | }; | |
| 332 | if plan["inherit"] == json!(true) { | |
| 333 | return outer; | |
| 334 | } | |
| 335 | let mut contexts = serde_json::Map::new(); | |
| 336 | if let Some(Value::Object(scope)) = plan.get("scope") { | |
| 337 | contexts.extend(scope.clone()); | |
| 338 | } | |
| 339 | contexts.insert("secrets".into(), Value::Object(outer)); | |
| 340 | contexts.insert("github".into(), github.clone()); | |
| 341 | contexts.insert("vars".into(), Value::Object(vars.clone())); | |
| 342 | let scope = g1t_actions::expr::Scope { contexts: &contexts, status: g1t_actions::expr::Status::Success, hash_files: None }; | |
| 343 | let mut passed = serde_json::Map::new(); | |
| 344 | for (name, expression) in plan["map"].as_object().into_iter().flatten() { | |
| 345 | let value = g1t_actions::expr::interpolate_value(expression, &scope).unwrap_or(Value::Null); | |
| 346 | let text = g1t_actions::expr::to_text(&value); | |
| 347 | if !text.is_empty() { | |
| 348 | passed.insert(name.clone(), Value::String(text)); | |
| 349 | } | |
| 350 | } | |
| 351 | passed | |
| 352 | } | |
| 353 | ||
| 354 | /// A public repository's file on GitHub, if it is there. | |
| 355 | async fn github_file(repository: &str, git_ref: &str, path: &str) -> Option<String> { | |
| 356 | let url = format!("https://raw.githubusercontent.com/{repository}/{git_ref}/{path}"); | |
| 357 | let headers = worker::Headers::new(); | |
| 358 | headers.set("user-agent", "g1t-actions").ok()?; | |
| 359 | let mut init = worker::RequestInit::new(); | |
| 360 | init.with_method(worker::Method::Get).with_headers(headers); | |
| 361 | let request = worker::Request::new_with_init(&url, &init).ok()?; | |
| 362 | let mut response = worker::Fetch::Request(request).send().await.ok()?; | |
| 363 | if response.status_code() != 200 { | |
| 364 | return None; | |
| 365 | } | |
| 366 | response.text().await.ok() | |
| 367 | } | |
| 368 | ||
| 369 | impl Actions { | |
| 370 | /// The workflow file a job's `uses:` calls, its text, and where it | |
| 371 | /// came from (`origin`, kept with its jobs so a `./` call inside it | |
| 372 | /// reads from the same place): `{"source": "g1t" | "github", "repo", | |
| 373 | /// "ref"}`. Err says why it cannot be called. | |
| 374 | pub(crate) async fn called_workflow( | |
| 375 | &self, | |
| 376 | run: &crate::plan::RunRow, | |
| 377 | row: &crate::plan::JobRow, | |
| 378 | uses: &str, | |
| 379 | ) -> Result<std::result::Result<(String, String, Value), String>> { | |
| 380 | let Some(ws) = self.workspace_actor(&crate::repo_path(&run.repo).namespace).await? else { | |
| 381 | return Ok(Err("The workspace is gone.".to_owned())); | |
| 382 | }; | |
| 383 | let (origin, file) = match parse_uses(uses) { | |
| 384 | None => { | |
| 385 | let Some(local) = uses.trim().strip_prefix("./") else { | |
| 386 | return Ok(Err(format!("`{uses}` is not a workflow: name one as ./.g1t/workflows/build.yml or owner/repo/.g1t/workflows/build.yml@ref."))); | |
| 387 | }; | |
| 388 | // In the same repository and commit as the workflow the | |
| 389 | // calling job is in: the run's, or the called workflow's. | |
| 390 | let origin = row | |
| 391 | .call() | |
| 392 | .filter(|call| call["role"] == "callee") | |
| 393 | .and_then(|call| call.get("origin").cloned()) | |
| 394 | .filter(Value::is_object) | |
| 395 | .unwrap_or_else(|| json!({ "source": "g1t", "repo": run.repo, "ref": run.sha })); | |
| 396 | (origin, local.split('@').next().unwrap_or(local).to_owned()) | |
| 397 | } | |
| 398 | Some(used) => { | |
| 399 | if !is_workflow_path(&used.path) { | |
| 400 | return Ok(Err(format!("`{uses}` is not a workflow file: it is under .g1t/workflows/ or .github/workflows/ and ends .yml or .yaml."))); | |
| 401 | } | |
| 402 | let Some((caller, _)) = self.repo_by_id(&run.repo_id).await? else { | |
| 403 | return Ok(Err("The repository is gone.".to_owned())); | |
| 404 | }; | |
| 405 | let origin = match self.find_used(&caller, &ws, &used).await? { | |
| 406 | Outcome::Fail(refused) => return Ok(Err(refused.message)), | |
| 407 | Outcome::Ok(Found::G1t(target)) => { | |
| 408 | json!({ "source": "g1t", "repo": format!("{}/{}", target.namespace, target.name), "ref": used.git_ref }) | |
| 409 | } | |
| 410 | Outcome::Ok(Found::GitHub) => json!({ "source": "github", "repo": used.full_name(), "ref": used.git_ref }), | |
| 411 | }; | |
| 412 | (origin, used.path) | |
| 413 | } | |
| 414 | }; | |
| 415 | let repository = origin["repo"].as_str().unwrap_or_default().to_owned(); | |
| 416 | let git_ref = origin["ref"].as_str().unwrap_or_default().to_owned(); | |
| 417 | let on_github = origin["source"] == "github"; | |
| 418 | for path in candidates(&file) { | |
| 419 | let text = if on_github { | |
| 420 | github_file(&repository, &git_ref, &path).await | |
| 421 | } else { | |
| 422 | self.read_file(&crate::repo_path(&repository), &ws, &git_ref, &path).await? | |
| 423 | }; | |
| 424 | if let Some(text) = text { | |
| 425 | // Shown as the repository names it, when it is another's. | |
| 426 | let shown = if repository.eq_ignore_ascii_case(&run.repo) { path } else { format!("{repository}/{path}@{git_ref}") }; | |
| 427 | return Ok(Ok((shown, text, origin))); | |
| 428 | } | |
| 429 | } | |
| 430 | Ok(Err(if repository.eq_ignore_ascii_case(&run.repo) { | |
| 431 | format!("`{uses}` is not in the repository at this commit.") | |
| 432 | } else if on_github { | |
| 433 | format!("`{uses}` was found neither on g1t nor in a public repository on GitHub.") | |
| 434 | } else { | |
| 435 | format!("`{uses}`: {repository} has no {file} at {git_ref}.") | |
| 436 | })) | |
| 437 | } | |
| 438 | } | |
| 439 | ||
| 440 | #[cfg(test)] | |
| 441 | mod tests { | |
| 442 | use super::*; | |
| 443 | ||
| 444 | #[test] | |
| 445 | fn a_called_workflow_gets_only_the_secrets_passed_to_it() { | |
| 446 | let base: serde_json::Map<String, Value> = | |
| 447 | serde_json::from_value(json!({ "NPM_TOKEN": "npm-1", "DEPLOY_KEY": "key-2", "OTHER": "x" })).unwrap(); | |
| 448 | let github = json!({ "ref": "refs/heads/main" }); | |
| 449 | let vars = serde_json::Map::new(); | |
| 450 | let contexts: serde_json::Map<String, Value> = serde_json::from_value(json!({ "needs": { "build": { "outputs": { "target": "prod" } } } })).unwrap(); | |
| 451 | // Nothing passed: nothing but the job's token, which is added later. | |
| 452 | let none = secrets_plan(&json!({ "uses": "acme/shared/.g1t/workflows/x.yml@v1" }), &contexts, None); | |
| 453 | assert!(resolve_secrets(&none, &base, &github, &vars).is_empty()); | |
| 454 | // inherit: all of them. | |
| 455 | let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, None); | |
| 456 | assert_eq!(resolve_secrets(&inherit, &base, &github, &vars), base); | |
| 457 | // A mapping: each name's expression, read with the caller's secrets | |
| 458 | // and contexts. | |
| 459 | let mapped = secrets_plan( | |
| 460 | &json!({ "secrets": { "token": "${{ secrets.NPM_TOKEN }}", "where": "${{ needs.build.outputs.target }}-${{ secrets.DEPLOY_KEY }}" } }), | |
| 461 | &contexts, | |
| 462 | None, | |
| 463 | ); | |
| 464 | let passed = resolve_secrets(&mapped, &base, &github, &vars); | |
| 465 | assert_eq!(passed.len(), 2); | |
| 466 | assert_eq!(passed["token"], "npm-1"); | |
| 467 | assert_eq!(passed["where"], "prod-key-2"); | |
| 468 | // Nested: the inner call reads what the outer one was given. | |
| 469 | let inner = secrets_plan(&json!({ "secrets": { "NPM": "${{ secrets.token }}", "LEAK": "${{ secrets.OTHER }}" } }), &contexts, Some(mapped.clone())); | |
| 470 | let passed = resolve_secrets(&inner, &base, &github, &vars); | |
| 471 | assert_eq!(passed.get("NPM"), Some(&json!("npm-1"))); | |
| 472 | assert_eq!(passed.get("LEAK"), None); | |
| 473 | let inherited = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, Some(mapped)); | |
| 474 | assert_eq!(resolve_secrets(&inherited, &base, &github, &vars).len(), 2); | |
| 475 | } | |
| 476 | ||
| 477 | #[test] | |
| 478 | fn required_secrets_must_be_passed() { | |
| 479 | let called = g1t_actions::workflow::parse( | |
| 480 | "on:\n workflow_call:\n secrets:\n token: { required: true }\n extra: { required: false }\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]", | |
| 481 | ) | |
| 482 | .unwrap() | |
| 483 | .raw; | |
| 484 | let none = secrets_plan(&json!({}), &serde_json::Map::new(), None); | |
| 485 | assert_eq!(missing_secrets(&called, &none), ["token"]); | |
| 486 | let passed = secrets_plan(&json!({ "secrets": { "TOKEN": "${{ secrets.X }}" } }), &serde_json::Map::new(), None); | |
| 487 | assert!(missing_secrets(&called, &passed).is_empty()); | |
| 488 | let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &serde_json::Map::new(), None); | |
| 489 | assert!(missing_secrets(&called, &inherit).is_empty()); | |
| 490 | } | |
| 491 | ||
| 492 | #[test] | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 493 | fn a_called_workflows_inputs_are_checked_against_their_types() { |
| 494 | let declared: serde_json::Map<String, Value> = serde_json::from_value(json!({ | |
| 495 | "flag": { "type": "boolean" }, | |
| 496 | "count": { "type": "number", "default": 2 }, | |
| 497 | "name": { "type": "string", "required": true }, | |
| 498 | "dry": { "type": "boolean", "default": "true" }, | |
| 499 | })) | |
| 500 | .unwrap(); | |
| 501 | let given = |value: Value| -> serde_json::Map<String, Value> { serde_json::from_value(value).unwrap() }; | |
| 502 | let file = ".g1t/workflows/build.yml"; | |
| 503 | // A string input takes a number as its text; what is not given takes | |
| 504 | // its default, else false, 0 or '' by its type. | |
| 505 | let inputs = call_inputs(file, &declared, &given(json!({ "name": 18 }))).unwrap(); | |
| 506 | assert_eq!(inputs["name"], json!("18")); | |
| 507 | assert_eq!(inputs["flag"], json!(false)); | |
| 508 | assert_eq!(inputs["count"], json!(2)); | |
| 509 | assert_eq!(inputs["dry"], json!(true)); | |
| 510 | let inputs = call_inputs(file, &declared, &given(json!({ "NAME": "x", "flag": true, "count": 3.5 }))).unwrap(); | |
| 511 | assert_eq!((inputs["name"].clone(), inputs["flag"].clone(), inputs["count"].clone()), (json!("x"), json!(true), json!(3.5))); | |
| 512 | // Text where a boolean or a number is declared is refused, as on GitHub. | |
| 513 | let wrong = call_inputs(file, &declared, &given(json!({ "name": "x", "flag": "true" }))).unwrap_err(); | |
| 514 | assert!(wrong.contains("`flag`") && wrong.contains("boolean") && wrong.contains("fromJSON"), "{wrong}"); | |
| 515 | assert!(call_inputs(file, &declared, &given(json!({ "name": "x", "count": "3" }))).unwrap_err().contains("number")); | |
| 516 | assert!(call_inputs(file, &declared, &given(json!({ "name": ["a"] }))).unwrap_err().contains("string")); | |
| 517 | // A required input left out, and one the workflow does not declare. | |
| 518 | assert_eq!(call_inputs(file, &declared, &given(json!({}))).unwrap_err(), "`.g1t/workflows/build.yml` needs the input `name`."); | |
| 519 | assert_eq!( | |
| 520 | call_inputs(file, &declared, &given(json!({ "name": "x", "extra": 1 }))).unwrap_err(), | |
| 521 | "Invalid input: `extra` is not defined in `.g1t/workflows/build.yml`." | |
| 522 | ); | |
| 523 | } | |
| 524 | ||
| 525 | #[test] | |
| 526 | fn secrets_a_called_workflow_does_not_declare_are_refused() { | |
| 527 | let called = g1t_actions::workflow::parse( | |
| 528 | "on:\n workflow_call:\n secrets:\n token: { required: false }\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]", | |
| 529 | ) | |
| 530 | .unwrap() | |
| 531 | .raw; | |
| 532 | let plan = secrets_plan(&json!({ "secrets": { "TOKEN": "x", "other": "y" } }), &serde_json::Map::new(), None); | |
| 533 | assert_eq!(undeclared_secrets(&called, &plan), ["other"]); | |
| 534 | assert!(undeclared_secrets(&called, &secrets_plan(&json!({ "secrets": "inherit" }), &serde_json::Map::new(), None)).is_empty()); | |
| 535 | } | |
| 536 | ||
| 537 | #[test] | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 538 | fn only_workflow_files_are_called() { |
| 539 | assert!(is_workflow_path(".g1t/workflows/build.yml")); | |
| 540 | assert!(is_workflow_path(".github/workflows/build.yaml")); | |
| 541 | assert!(!is_workflow_path("actions/setup/action.yml")); | |
| 542 | assert!(!is_workflow_path(".github/workflows/notes.md")); | |
| 543 | assert_eq!(candidates(".github/workflows/x.yml"), [".github/workflows/x.yml", ".g1t/workflows/x.yml"]); | |
| 544 | assert_eq!(candidates(".g1t/workflows/x.yml"), [".g1t/workflows/x.yml"]); | |
| 545 | } | |
| 546 | ||
| 547 | fn repo(id: &str, namespace: &str, private: bool) -> Repo { | |
| 548 | serde_json::from_value(json!({ | |
| 549 | "id": id, "namespace": namespace, "name": id, "description": null, "isPrivate": private, | |
| 550 | "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": "" | |
| 551 | })) | |
| 552 | .unwrap() | |
| 553 | } | |
| 554 | ||
| 555 | #[test] | |
| 556 | fn uses_names_a_repository_a_path_and_a_ref() { | |
| 557 | assert_eq!( | |
| 558 | parse_uses("acme/shared/.g1t/workflows/build.yml@v2"), | |
| 559 | Some(UsesRef { owner: "acme".into(), repo: "shared".into(), path: ".g1t/workflows/build.yml".into(), git_ref: "v2".into() }) | |
| 560 | ); | |
| 561 | assert_eq!(parse_uses("acme/setup@main").map(|u| (u.path, u.git_ref)), Some((String::new(), "main".into()))); | |
| 562 | assert_eq!(parse_uses("./.g1t/workflows/build.yml"), None); | |
| 563 | assert_eq!(parse_uses("docker://alpine:3"), None); | |
| 564 | assert_eq!(parse_uses("acme/setup"), None); | |
| 565 | assert_eq!(parse_uses("acme/../x@v1"), None); | |
| 566 | assert_eq!(parse_uses("acme/shared/../../etc@v1"), None); | |
| 567 | } | |
| 568 | ||
| 569 | #[test] | |
| 570 | fn who_may_use_a_repositorys_actions() { | |
| 571 | let web = repo("web", "acme", true); | |
| 572 | // Itself, and anything public, always. | |
| 573 | assert!(may_use(&web, &web, "none").is_ok()); | |
| 574 | assert!(may_use(&web, &repo("lint", "other", false), "none").is_ok()); | |
| 575 | // A private one: only when it allows its workspace's repositories. | |
| 576 | let shared = repo("shared", "acme", true); | |
| 577 | assert!(may_use(&web, &shared, "none").unwrap_err().contains("Settings, Actions, Access")); | |
| 578 | assert!(may_use(&web, &shared, "organization").is_ok()); | |
| 579 | // Never from another workspace, nor from a public repository. | |
| 580 | assert!(may_use(&repo("x", "other", true), &shared, "organization").unwrap_err().contains("only repositories in acme")); | |
| 581 | assert!(may_use(&repo("site", "acme", false), &shared, "organization").unwrap_err().contains("public repository")); | |
| 582 | } | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 583 | |
| 584 | #[test] | |
| 585 | fn only_its_own_workspace_or_g1t_answers_for_a_name_github_has() { | |
| 586 | let web = repo("web", "acme", true); | |
| 587 | // The caller's own workspace, and g1t's own actions, whatever GitHub has. | |
| 588 | assert!(answers_for_its_name(&web, &repo("setup", "acme", true))); | |
| 589 | assert!(answers_for_its_name(&web, &repo("setup", "ACME", false))); | |
| 590 | assert!(answers_for_its_name(&web, &repo("deploy", "g1t", false))); | |
| 591 | // Anyone else's public repository named like GitHub's `actions/setup-node` | |
| 592 | // does not: GitHub's is used when GitHub has one. | |
| 593 | assert!(!answers_for_its_name(&web, &repo("setup-node", "actions", false))); | |
| 594 | } | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 595 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.