Skip to content
440 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/// Where another repository's action or workflow comes from.
79pub(crate) enum Found {
80 /// g1t has it, and the caller may use it.
81 G1t(Repo),
82 /// g1t does not have it (that the caller's workspace can see): GitHub.
83 GitHub,
84}
85
86impl Actions {
87 /// Looks for `owner/repo` on g1t, as `caller`'s workspace (`ws`) sees
88 /// it, and checks the caller may use it.
89 pub(crate) async fn find_used(&self, caller: &Repo, ws: &User, used: &UsesRef) -> Result<Outcome<Found>> {
90 let path = RepoPath { namespace: used.owner.clone(), name: used.repo.clone() };
91 let Some(target) = self.visible_repo(&path, &Some(ws.clone())).await? else {
92 return Ok(Outcome::Ok(Found::GitHub));
93 };
94 let level = if target.is_private && target.id != caller.id { self.access_level_of(&target.id).await? } else { "none" };
95 Ok(match may_use(caller, &target, level) {
96 Ok(()) => Outcome::Ok(Found::G1t(target)),
97 Err(why) => fail(FailureCode::Forbidden, why),
98 })
99 }
100
101 /// `job_action`: a running job asks where to fetch an action from, by
102 /// `report.repository` (`owner/repo`) and `report.ref`. Answers
103 /// `{"source": "g1t", "url", "ref", "token"}` (a read-only token for
104 /// that repository, ending with the job, when it is private),
105 /// `{"source": "github", "sha", "archive", "note"}` (the commit its ref
106 /// names, and whether g1t keeps its archive; see registry.rs), or a
107 /// refusal saying why it may not be used.
108 pub async fn job_action(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<Outcome<Value>> {
109 let job = crate::check!(self.job_for_token(&a).await?);
110 let Some(run) = self.run_row(&job.run_id).await? else {
111 return Ok(fail(FailureCode::NotFound, "No such run."));
112 };
113 let repository = a.report["repository"].as_str().unwrap_or_default();
114 let git_ref = a.report["ref"].as_str().unwrap_or_default();
115 let Some(used) = parse_uses(&format!("{repository}@{git_ref}")) else {
116 return Ok(fail(FailureCode::Invalid, "Name the action's repository as owner/repo, and its ref."));
117 };
118 let Some((caller, ws)) = self.repo_by_id(&run.repo_id).await? else {
119 return Ok(fail(FailureCode::NotFound, "The repository is gone."));
120 };
121 let target = match crate::check!(self.find_used(&caller, &ws, &used).await?) {
122 Found::GitHub => return Ok(Outcome::Ok(self.github_action(&used).await?)),
123 Found::G1t(target) => target,
124 };
125 let full_name = format!("{}/{}", target.namespace, target.name);
126 // A private repository is read with a token of its own: read-only,
127 // for that repository alone, ending with the job.
128 let token = if target.is_private {
129 let created: g1t_contracts::identity::CreatedAccessToken = g1t_kit::call(
130 &self.identity,
131 "create_job_token",
132 &g1t_contracts::identity::CreateJobTokenArgs {
133 workspace: ws.clone(),
134 repo: RepoPath { namespace: target.namespace.clone(), name: target.name.clone() },
135 run_id: run.id.clone(),
136 job_id: job.id.clone(),
137 name: format!("Action {full_name} for {} run {}", run.repo, run.number),
138 ttl_seconds: u64::from(job.timeout_minutes) * 60 + 600,
139 scopes: vec!["repo:read".to_owned(), "code:read".to_owned()],
140 pull_requests: false,
141 },
142 )
143 .await?;
144 Value::String(created.token)
145 } else {
146 Value::Null
147 };
148 Ok(Outcome::Ok(json!({
149 "source": "g1t",
150 "repository": full_name,
151 "url": format!("{SITE}/{full_name}.git"),
152 "ref": used.git_ref,
153 "token": token,
154 })))
155 }
156}
157
158/// Whether `path` is a workflow file: `.g1t/workflows/…` or
159/// `.github/workflows/…`, ending `.yml` or `.yaml`.
160pub(crate) fn is_workflow_path(path: &str) -> bool {
161 (path.starts_with(".g1t/workflows/") || path.starts_with(".github/workflows/")) && (path.ends_with(".yml") || path.ends_with(".yaml"))
162}
163
164/// The paths to try for a workflow file: as written, and for `.github/…`
165/// also `.g1t/…`, where a repository moved to g1t keeps it.
166fn candidates(path: &str) -> Vec<String> {
167 let mut paths = vec![path.to_owned()];
168 if let Some(rest) = path.strip_prefix(".github/") {
169 paths.push(format!(".g1t/{rest}"));
170 }
171 paths
172}
173
174/// What a job's `secrets:` passes to the workflow it calls, kept with the
175/// called jobs until they start, when it is read with the secrets
176/// themselves (`resolve_secrets`); no secret is stored. `inherit` passes
177/// all of the caller's; a mapping passes each name's expression, read with
178/// the caller's `secrets` and the contexts it had (`needs`, `inputs`,
179/// `matrix`); nothing passes none. `outer` is the caller's own, when the
180/// caller is itself a called workflow's job.
181pub(crate) fn secrets_plan(job_raw: &Value, contexts: &serde_json::Map<String, Value>, outer: Option<Value>) -> Value {
182 let mut plan = match job_raw.get("secrets") {
183 Some(Value::String(text)) if text.trim() == "inherit" => json!({ "inherit": true }),
184 Some(Value::Object(map)) => json!({
185 "map": map,
186 "scope": {
187 "needs": contexts.get("needs").cloned().unwrap_or_else(|| json!({})),
188 "inputs": contexts.get("inputs").cloned().unwrap_or_else(|| json!({})),
189 "matrix": contexts.get("matrix").cloned().unwrap_or_else(|| json!({})),
190 },
191 }),
192 _ => json!({ "map": {} }),
193 };
194 if let Some(outer) = outer.filter(Value::is_object) {
195 plan["outer"] = outer;
196 }
197 plan
198}
199
200/// The secrets a called workflow says are required
201/// (`on.workflow_call.secrets.<name>.required`) that `plan` does not pass.
202/// `inherit` passes whatever the caller has, so it is not checked here.
203pub(crate) fn missing_secrets(called_raw: &Value, plan: &Value) -> Vec<String> {
204 if plan["inherit"] == json!(true) {
205 return Vec::new();
206 }
207 let on = called_raw.get("on").or_else(|| called_raw.get("true")).cloned().unwrap_or(Value::Null);
208 let Some(Value::Object(declared)) = on.get("workflow_call").and_then(|call| call.get("secrets")).cloned() else {
209 return Vec::new();
210 };
211 let passed = plan["map"].as_object().cloned().unwrap_or_default();
212 declared
213 .iter()
214 .filter(|(_, spec)| spec.get("required").and_then(Value::as_bool) == Some(true))
215 .filter(|(name, _)| !passed.keys().any(|key| key.eq_ignore_ascii_case(name)))
216 .map(|(name, _)| name.clone())
217 .collect()
218}
219
220/// The `secrets` a called workflow's job gets, by `plan` (`secrets_plan`),
221/// from `base` (the repository's secrets, as the top caller has them),
222/// with `github` and `vars` for the expressions. The job's token is added
223/// by the caller of this, as every job's is.
224pub(crate) fn resolve_secrets(
225 plan: &Value,
226 base: &serde_json::Map<String, Value>,
227 github: &Value,
228 vars: &serde_json::Map<String, Value>,
229) -> serde_json::Map<String, Value> {
230 let outer = match plan.get("outer").filter(|outer| outer.is_object()) {
231 Some(outer) => resolve_secrets(outer, base, github, vars),
232 None => base.clone(),
233 };
234 if plan["inherit"] == json!(true) {
235 return outer;
236 }
237 let mut contexts = serde_json::Map::new();
238 if let Some(Value::Object(scope)) = plan.get("scope") {
239 contexts.extend(scope.clone());
240 }
241 contexts.insert("secrets".into(), Value::Object(outer));
242 contexts.insert("github".into(), github.clone());
243 contexts.insert("vars".into(), Value::Object(vars.clone()));
244 let scope = g1t_actions::expr::Scope { contexts: &contexts, status: g1t_actions::expr::Status::Success, hash_files: None };
245 let mut passed = serde_json::Map::new();
246 for (name, expression) in plan["map"].as_object().into_iter().flatten() {
247 let value = g1t_actions::expr::interpolate_value(expression, &scope).unwrap_or(Value::Null);
248 let text = g1t_actions::expr::to_text(&value);
249 if !text.is_empty() {
250 passed.insert(name.clone(), Value::String(text));
251 }
252 }
253 passed
254}
255
256/// A public repository's file on GitHub, if it is there.
257async fn github_file(repository: &str, git_ref: &str, path: &str) -> Option<String> {
258 let url = format!("https://raw.githubusercontent.com/{repository}/{git_ref}/{path}");
259 let headers = worker::Headers::new();
260 headers.set("user-agent", "g1t-actions").ok()?;
261 let mut init = worker::RequestInit::new();
262 init.with_method(worker::Method::Get).with_headers(headers);
263 let request = worker::Request::new_with_init(&url, &init).ok()?;
264 let mut response = worker::Fetch::Request(request).send().await.ok()?;
265 if response.status_code() != 200 {
266 return None;
267 }
268 response.text().await.ok()
269}
270
271impl Actions {
272 /// The workflow file a job's `uses:` calls, its text, and where it
273 /// came from (`origin`, kept with its jobs so a `./` call inside it
274 /// reads from the same place): `{"source": "g1t" | "github", "repo",
275 /// "ref"}`. Err says why it cannot be called.
276 pub(crate) async fn called_workflow(
277 &self,
278 run: &crate::plan::RunRow,
279 row: &crate::plan::JobRow,
280 uses: &str,
281 ) -> Result<std::result::Result<(String, String, Value), String>> {
282 let Some(ws) = self.workspace_actor(&crate::repo_path(&run.repo).namespace).await? else {
283 return Ok(Err("The workspace is gone.".to_owned()));
284 };
285 let (origin, file) = match parse_uses(uses) {
286 None => {
287 let Some(local) = uses.trim().strip_prefix("./") else {
288 return Ok(Err(format!("`{uses}` is not a workflow: name one as ./.g1t/workflows/build.yml or owner/repo/.g1t/workflows/build.yml@ref.")));
289 };
290 // In the same repository and commit as the workflow the
291 // calling job is in: the run's, or the called workflow's.
292 let origin = row
293 .call()
294 .filter(|call| call["role"] == "callee")
295 .and_then(|call| call.get("origin").cloned())
296 .filter(Value::is_object)
297 .unwrap_or_else(|| json!({ "source": "g1t", "repo": run.repo, "ref": run.sha }));
298 (origin, local.split('@').next().unwrap_or(local).to_owned())
299 }
300 Some(used) => {
301 if !is_workflow_path(&used.path) {
302 return Ok(Err(format!("`{uses}` is not a workflow file: it is under .g1t/workflows/ or .github/workflows/ and ends .yml or .yaml.")));
303 }
304 let Some((caller, _)) = self.repo_by_id(&run.repo_id).await? else {
305 return Ok(Err("The repository is gone.".to_owned()));
306 };
307 let origin = match self.find_used(&caller, &ws, &used).await? {
308 Outcome::Fail(refused) => return Ok(Err(refused.message)),
309 Outcome::Ok(Found::G1t(target)) => {
310 json!({ "source": "g1t", "repo": format!("{}/{}", target.namespace, target.name), "ref": used.git_ref })
311 }
312 Outcome::Ok(Found::GitHub) => json!({ "source": "github", "repo": used.full_name(), "ref": used.git_ref }),
313 };
314 (origin, used.path)
315 }
316 };
317 let repository = origin["repo"].as_str().unwrap_or_default().to_owned();
318 let git_ref = origin["ref"].as_str().unwrap_or_default().to_owned();
319 let on_github = origin["source"] == "github";
320 for path in candidates(&file) {
321 let text = if on_github {
322 github_file(&repository, &git_ref, &path).await
323 } else {
324 self.read_file(&crate::repo_path(&repository), &ws, &git_ref, &path).await?
325 };
326 if let Some(text) = text {
327 // Shown as the repository names it, when it is another's.
328 let shown = if repository.eq_ignore_ascii_case(&run.repo) { path } else { format!("{repository}/{path}@{git_ref}") };
329 return Ok(Ok((shown, text, origin)));
330 }
331 }
332 Ok(Err(if repository.eq_ignore_ascii_case(&run.repo) {
333 format!("`{uses}` is not in the repository at this commit.")
334 } else if on_github {
335 format!("`{uses}` was found neither on g1t nor in a public repository on GitHub.")
336 } else {
337 format!("`{uses}`: {repository} has no {file} at {git_ref}.")
338 }))
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn a_called_workflow_gets_only_the_secrets_passed_to_it() {
348 let base: serde_json::Map<String, Value> =
349 serde_json::from_value(json!({ "NPM_TOKEN": "npm-1", "DEPLOY_KEY": "key-2", "OTHER": "x" })).unwrap();
350 let github = json!({ "ref": "refs/heads/main" });
351 let vars = serde_json::Map::new();
352 let contexts: serde_json::Map<String, Value> = serde_json::from_value(json!({ "needs": { "build": { "outputs": { "target": "prod" } } } })).unwrap();
353 // Nothing passed: nothing but the job's token, which is added later.
354 let none = secrets_plan(&json!({ "uses": "acme/shared/.g1t/workflows/x.yml@v1" }), &contexts, None);
355 assert!(resolve_secrets(&none, &base, &github, &vars).is_empty());
356 // inherit: all of them.
357 let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, None);
358 assert_eq!(resolve_secrets(&inherit, &base, &github, &vars), base);
359 // A mapping: each name's expression, read with the caller's secrets
360 // and contexts.
361 let mapped = secrets_plan(
362 &json!({ "secrets": { "token": "${{ secrets.NPM_TOKEN }}", "where": "${{ needs.build.outputs.target }}-${{ secrets.DEPLOY_KEY }}" } }),
363 &contexts,
364 None,
365 );
366 let passed = resolve_secrets(&mapped, &base, &github, &vars);
367 assert_eq!(passed.len(), 2);
368 assert_eq!(passed["token"], "npm-1");
369 assert_eq!(passed["where"], "prod-key-2");
370 // Nested: the inner call reads what the outer one was given.
371 let inner = secrets_plan(&json!({ "secrets": { "NPM": "${{ secrets.token }}", "LEAK": "${{ secrets.OTHER }}" } }), &contexts, Some(mapped.clone()));
372 let passed = resolve_secrets(&inner, &base, &github, &vars);
373 assert_eq!(passed.get("NPM"), Some(&json!("npm-1")));
374 assert_eq!(passed.get("LEAK"), None);
375 let inherited = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, Some(mapped));
376 assert_eq!(resolve_secrets(&inherited, &base, &github, &vars).len(), 2);
377 }
378
379 #[test]
380 fn required_secrets_must_be_passed() {
381 let called = g1t_actions::workflow::parse(
382 "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' }]",
383 )
384 .unwrap()
385 .raw;
386 let none = secrets_plan(&json!({}), &serde_json::Map::new(), None);
387 assert_eq!(missing_secrets(&called, &none), ["token"]);
388 let passed = secrets_plan(&json!({ "secrets": { "TOKEN": "${{ secrets.X }}" } }), &serde_json::Map::new(), None);
389 assert!(missing_secrets(&called, &passed).is_empty());
390 let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &serde_json::Map::new(), None);
391 assert!(missing_secrets(&called, &inherit).is_empty());
392 }
393
394 #[test]
395 fn only_workflow_files_are_called() {
396 assert!(is_workflow_path(".g1t/workflows/build.yml"));
397 assert!(is_workflow_path(".github/workflows/build.yaml"));
398 assert!(!is_workflow_path("actions/setup/action.yml"));
399 assert!(!is_workflow_path(".github/workflows/notes.md"));
400 assert_eq!(candidates(".github/workflows/x.yml"), [".github/workflows/x.yml", ".g1t/workflows/x.yml"]);
401 assert_eq!(candidates(".g1t/workflows/x.yml"), [".g1t/workflows/x.yml"]);
402 }
403
404 fn repo(id: &str, namespace: &str, private: bool) -> Repo {
405 serde_json::from_value(json!({
406 "id": id, "namespace": namespace, "name": id, "description": null, "isPrivate": private,
407 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
408 }))
409 .unwrap()
410 }
411
412 #[test]
413 fn uses_names_a_repository_a_path_and_a_ref() {
414 assert_eq!(
415 parse_uses("acme/shared/.g1t/workflows/build.yml@v2"),
416 Some(UsesRef { owner: "acme".into(), repo: "shared".into(), path: ".g1t/workflows/build.yml".into(), git_ref: "v2".into() })
417 );
418 assert_eq!(parse_uses("acme/setup@main").map(|u| (u.path, u.git_ref)), Some((String::new(), "main".into())));
419 assert_eq!(parse_uses("./.g1t/workflows/build.yml"), None);
420 assert_eq!(parse_uses("docker://alpine:3"), None);
421 assert_eq!(parse_uses("acme/setup"), None);
422 assert_eq!(parse_uses("acme/../x@v1"), None);
423 assert_eq!(parse_uses("acme/shared/../../etc@v1"), None);
424 }
425
426 #[test]
427 fn who_may_use_a_repositorys_actions() {
428 let web = repo("web", "acme", true);
429 // Itself, and anything public, always.
430 assert!(may_use(&web, &web, "none").is_ok());
431 assert!(may_use(&web, &repo("lint", "other", false), "none").is_ok());
432 // A private one: only when it allows its workspace's repositories.
433 let shared = repo("shared", "acme", true);
434 assert!(may_use(&web, &shared, "none").unwrap_err().contains("Settings, Actions, Access"));
435 assert!(may_use(&web, &shared, "organization").is_ok());
436 // Never from another workspace, nor from a public repository.
437 assert!(may_use(&repo("x", "other", true), &shared, "organization").unwrap_err().contains("only repositories in acme"));
438 assert!(may_use(&repo("site", "acme", false), &shared, "organization").unwrap_err().contains("public repository"));
439 }
440}