Skip to content
468 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` a called workflow's job gets, by `plan` (`secrets_plan`),
237/// from `base` (the repository's secrets, as the top caller has them),
238/// with `github` and `vars` for the expressions. The job's token is added
239/// by the caller of this, as every job's is.
240pub(crate) fn resolve_secrets(
241 plan: &Value,
242 base: &serde_json::Map<String, Value>,
243 github: &Value,
244 vars: &serde_json::Map<String, Value>,
245) -> serde_json::Map<String, Value> {
246 let outer = match plan.get("outer").filter(|outer| outer.is_object()) {
247 Some(outer) => resolve_secrets(outer, base, github, vars),
248 None => base.clone(),
249 };
250 if plan["inherit"] == json!(true) {
251 return outer;
252 }
253 let mut contexts = serde_json::Map::new();
254 if let Some(Value::Object(scope)) = plan.get("scope") {
255 contexts.extend(scope.clone());
256 }
257 contexts.insert("secrets".into(), Value::Object(outer));
258 contexts.insert("github".into(), github.clone());
259 contexts.insert("vars".into(), Value::Object(vars.clone()));
260 let scope = g1t_actions::expr::Scope { contexts: &contexts, status: g1t_actions::expr::Status::Success, hash_files: None };
261 let mut passed = serde_json::Map::new();
262 for (name, expression) in plan["map"].as_object().into_iter().flatten() {
263 let value = g1t_actions::expr::interpolate_value(expression, &scope).unwrap_or(Value::Null);
264 let text = g1t_actions::expr::to_text(&value);
265 if !text.is_empty() {
266 passed.insert(name.clone(), Value::String(text));
267 }
268 }
269 passed
270}
271
272/// A public repository's file on GitHub, if it is there.
273async fn github_file(repository: &str, git_ref: &str, path: &str) -> Option<String> {
274 let url = format!("https://raw.githubusercontent.com/{repository}/{git_ref}/{path}");
275 let headers = worker::Headers::new();
276 headers.set("user-agent", "g1t-actions").ok()?;
277 let mut init = worker::RequestInit::new();
278 init.with_method(worker::Method::Get).with_headers(headers);
279 let request = worker::Request::new_with_init(&url, &init).ok()?;
280 let mut response = worker::Fetch::Request(request).send().await.ok()?;
281 if response.status_code() != 200 {
282 return None;
283 }
284 response.text().await.ok()
285}
286
287impl Actions {
288 /// The workflow file a job's `uses:` calls, its text, and where it
289 /// came from (`origin`, kept with its jobs so a `./` call inside it
290 /// reads from the same place): `{"source": "g1t" | "github", "repo",
291 /// "ref"}`. Err says why it cannot be called.
292 pub(crate) async fn called_workflow(
293 &self,
294 run: &crate::plan::RunRow,
295 row: &crate::plan::JobRow,
296 uses: &str,
297 ) -> Result<std::result::Result<(String, String, Value), String>> {
298 let Some(ws) = self.workspace_actor(&crate::repo_path(&run.repo).namespace).await? else {
299 return Ok(Err("The workspace is gone.".to_owned()));
300 };
301 let (origin, file) = match parse_uses(uses) {
302 None => {
303 let Some(local) = uses.trim().strip_prefix("./") else {
304 return Ok(Err(format!("`{uses}` is not a workflow: name one as ./.g1t/workflows/build.yml or owner/repo/.g1t/workflows/build.yml@ref.")));
305 };
306 // In the same repository and commit as the workflow the
307 // calling job is in: the run's, or the called workflow's.
308 let origin = row
309 .call()
310 .filter(|call| call["role"] == "callee")
311 .and_then(|call| call.get("origin").cloned())
312 .filter(Value::is_object)
313 .unwrap_or_else(|| json!({ "source": "g1t", "repo": run.repo, "ref": run.sha }));
314 (origin, local.split('@').next().unwrap_or(local).to_owned())
315 }
316 Some(used) => {
317 if !is_workflow_path(&used.path) {
318 return Ok(Err(format!("`{uses}` is not a workflow file: it is under .g1t/workflows/ or .github/workflows/ and ends .yml or .yaml.")));
319 }
320 let Some((caller, _)) = self.repo_by_id(&run.repo_id).await? else {
321 return Ok(Err("The repository is gone.".to_owned()));
322 };
323 let origin = match self.find_used(&caller, &ws, &used).await? {
324 Outcome::Fail(refused) => return Ok(Err(refused.message)),
325 Outcome::Ok(Found::G1t(target)) => {
326 json!({ "source": "g1t", "repo": format!("{}/{}", target.namespace, target.name), "ref": used.git_ref })
327 }
328 Outcome::Ok(Found::GitHub) => json!({ "source": "github", "repo": used.full_name(), "ref": used.git_ref }),
329 };
330 (origin, used.path)
331 }
332 };
333 let repository = origin["repo"].as_str().unwrap_or_default().to_owned();
334 let git_ref = origin["ref"].as_str().unwrap_or_default().to_owned();
335 let on_github = origin["source"] == "github";
336 for path in candidates(&file) {
337 let text = if on_github {
338 github_file(&repository, &git_ref, &path).await
339 } else {
340 self.read_file(&crate::repo_path(&repository), &ws, &git_ref, &path).await?
341 };
342 if let Some(text) = text {
343 // Shown as the repository names it, when it is another's.
344 let shown = if repository.eq_ignore_ascii_case(&run.repo) { path } else { format!("{repository}/{path}@{git_ref}") };
345 return Ok(Ok((shown, text, origin)));
346 }
347 }
348 Ok(Err(if repository.eq_ignore_ascii_case(&run.repo) {
349 format!("`{uses}` is not in the repository at this commit.")
350 } else if on_github {
351 format!("`{uses}` was found neither on g1t nor in a public repository on GitHub.")
352 } else {
353 format!("`{uses}`: {repository} has no {file} at {git_ref}.")
354 }))
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn a_called_workflow_gets_only_the_secrets_passed_to_it() {
364 let base: serde_json::Map<String, Value> =
365 serde_json::from_value(json!({ "NPM_TOKEN": "npm-1", "DEPLOY_KEY": "key-2", "OTHER": "x" })).unwrap();
366 let github = json!({ "ref": "refs/heads/main" });
367 let vars = serde_json::Map::new();
368 let contexts: serde_json::Map<String, Value> = serde_json::from_value(json!({ "needs": { "build": { "outputs": { "target": "prod" } } } })).unwrap();
369 // Nothing passed: nothing but the job's token, which is added later.
370 let none = secrets_plan(&json!({ "uses": "acme/shared/.g1t/workflows/x.yml@v1" }), &contexts, None);
371 assert!(resolve_secrets(&none, &base, &github, &vars).is_empty());
372 // inherit: all of them.
373 let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, None);
374 assert_eq!(resolve_secrets(&inherit, &base, &github, &vars), base);
375 // A mapping: each name's expression, read with the caller's secrets
376 // and contexts.
377 let mapped = secrets_plan(
378 &json!({ "secrets": { "token": "${{ secrets.NPM_TOKEN }}", "where": "${{ needs.build.outputs.target }}-${{ secrets.DEPLOY_KEY }}" } }),
379 &contexts,
380 None,
381 );
382 let passed = resolve_secrets(&mapped, &base, &github, &vars);
383 assert_eq!(passed.len(), 2);
384 assert_eq!(passed["token"], "npm-1");
385 assert_eq!(passed["where"], "prod-key-2");
386 // Nested: the inner call reads what the outer one was given.
387 let inner = secrets_plan(&json!({ "secrets": { "NPM": "${{ secrets.token }}", "LEAK": "${{ secrets.OTHER }}" } }), &contexts, Some(mapped.clone()));
388 let passed = resolve_secrets(&inner, &base, &github, &vars);
389 assert_eq!(passed.get("NPM"), Some(&json!("npm-1")));
390 assert_eq!(passed.get("LEAK"), None);
391 let inherited = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, Some(mapped));
392 assert_eq!(resolve_secrets(&inherited, &base, &github, &vars).len(), 2);
393 }
394
395 #[test]
396 fn required_secrets_must_be_passed() {
397 let called = g1t_actions::workflow::parse(
398 "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' }]",
399 )
400 .unwrap()
401 .raw;
402 let none = secrets_plan(&json!({}), &serde_json::Map::new(), None);
403 assert_eq!(missing_secrets(&called, &none), ["token"]);
404 let passed = secrets_plan(&json!({ "secrets": { "TOKEN": "${{ secrets.X }}" } }), &serde_json::Map::new(), None);
405 assert!(missing_secrets(&called, &passed).is_empty());
406 let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &serde_json::Map::new(), None);
407 assert!(missing_secrets(&called, &inherit).is_empty());
408 }
409
410 #[test]
411 fn only_workflow_files_are_called() {
412 assert!(is_workflow_path(".g1t/workflows/build.yml"));
413 assert!(is_workflow_path(".github/workflows/build.yaml"));
414 assert!(!is_workflow_path("actions/setup/action.yml"));
415 assert!(!is_workflow_path(".github/workflows/notes.md"));
416 assert_eq!(candidates(".github/workflows/x.yml"), [".github/workflows/x.yml", ".g1t/workflows/x.yml"]);
417 assert_eq!(candidates(".g1t/workflows/x.yml"), [".g1t/workflows/x.yml"]);
418 }
419
420 fn repo(id: &str, namespace: &str, private: bool) -> Repo {
421 serde_json::from_value(json!({
422 "id": id, "namespace": namespace, "name": id, "description": null, "isPrivate": private,
423 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
424 }))
425 .unwrap()
426 }
427
428 #[test]
429 fn uses_names_a_repository_a_path_and_a_ref() {
430 assert_eq!(
431 parse_uses("acme/shared/.g1t/workflows/build.yml@v2"),
432 Some(UsesRef { owner: "acme".into(), repo: "shared".into(), path: ".g1t/workflows/build.yml".into(), git_ref: "v2".into() })
433 );
434 assert_eq!(parse_uses("acme/setup@main").map(|u| (u.path, u.git_ref)), Some((String::new(), "main".into())));
435 assert_eq!(parse_uses("./.g1t/workflows/build.yml"), None);
436 assert_eq!(parse_uses("docker://alpine:3"), None);
437 assert_eq!(parse_uses("acme/setup"), None);
438 assert_eq!(parse_uses("acme/../x@v1"), None);
439 assert_eq!(parse_uses("acme/shared/../../etc@v1"), None);
440 }
441
442 #[test]
443 fn who_may_use_a_repositorys_actions() {
444 let web = repo("web", "acme", true);
445 // Itself, and anything public, always.
446 assert!(may_use(&web, &web, "none").is_ok());
447 assert!(may_use(&web, &repo("lint", "other", false), "none").is_ok());
448 // A private one: only when it allows its workspace's repositories.
449 let shared = repo("shared", "acme", true);
450 assert!(may_use(&web, &shared, "none").unwrap_err().contains("Settings, Actions, Access"));
451 assert!(may_use(&web, &shared, "organization").is_ok());
452 // Never from another workspace, nor from a public repository.
453 assert!(may_use(&repo("x", "other", true), &shared, "organization").unwrap_err().contains("only repositories in acme"));
454 assert!(may_use(&repo("site", "acme", false), &shared, "organization").unwrap_err().contains("public repository"));
455 }
456
457 #[test]
458 fn only_its_own_workspace_or_g1t_answers_for_a_name_github_has() {
459 let web = repo("web", "acme", true);
460 // The caller's own workspace, and g1t's own actions, whatever GitHub has.
461 assert!(answers_for_its_name(&web, &repo("setup", "acme", true)));
462 assert!(answers_for_its_name(&web, &repo("setup", "ACME", false)));
463 assert!(answers_for_its_name(&web, &repo("deploy", "g1t", false)));
464 // Anyone else's public repository named like GitHub's `actions/setup-node`
465 // does not: GitHub's is used when GitHub has one.
466 assert!(!answers_for_its_name(&web, &repo("setup-node", "actions", false)));
467 }
468}