Skip to content
595 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
14use g1t_contracts::repos::{Repo, RepoPath};
15use g1t_contracts::{FailureCode, Outcome, User};
16use serde_json::{Value, json};
17use worker::Result;
18
19use crate::{Actions, SITE, fail};
20
21/// What `uses:` names in another repository.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub(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
32impl 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.
40pub(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.
59pub(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
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.
83pub(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
87/// Where another repository's action or workflow comes from.
88pub(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
95impl 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 };
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 }
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),
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.
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?) {
138 Found::GitHub => return Ok(Outcome::Ok(self.github_action(&used).await?)),
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`.
176pub(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.
182fn 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.
197pub(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.
219pub(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
236/// The secrets `plan` passes by name that the called workflow does not
237/// declare under `on.workflow_call.secrets`, which GitHub refuses.
238pub(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.
259pub(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.
298fn 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.
308fn 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
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.
322pub(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.
355async 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
369impl 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)]
441mod 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]
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]
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 }
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 }
595}