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