Skip to content
595 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1//! 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
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'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
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts87/// 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 };
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'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 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts110 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),
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'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.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts124 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?) {
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'138 Found::GitHub => return Ok(Outcome::Ok(self.github_action(&used).await?)),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts139 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
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts318/// 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]
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts538 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 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'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 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts595}

This file's history is long; its oldest lines are credited to the oldest commit read.