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