Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
693 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.
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.13//!
14//! An owner that is a workspace alias g1t's staff set (identity's
15//! aliases.rs) is looked for under the workspace's own slug when nothing
16//! has the name as written: `g1t/deploy@v1` is `flagon-io/deploy`, as
17//! `g1t` is `flagon-io`'s alias. The repository found is named by its own
18//! path from then on (the job's download, the called workflow's origin),
19//! and is checked exactly as if it had been named that way: the alias
20//! changes where g1t looks, never who may see or use what it finds.
21//!
22//! `g1t/` names g1t's own actions. Nobody can register `g1t`, so such a
23//! repository answers for its name even where GitHub has one of the same
24//! name, but only when it is public; and a `g1t/` name g1t does not have
25//! (or that the workspace cannot see) is never taken from GitHub, where
26//! anyone might own `g1t`.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts27
28use g1t_contracts::repos::{Repo, RepoPath};
29use g1t_contracts::{FailureCode, Outcome, User};
30use serde_json::{Value, json};
31use worker::Result;
32
33use crate::{Actions, SITE, fail};
34
35/// What `uses:` names in another repository.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub(crate) struct UsesRef {
38 pub(crate) owner: String,
39 pub(crate) repo: String,
40 /// Inside the repository: an action's folder, or a workflow's file.
41 /// Empty for an action at its root.
42 pub(crate) path: String,
43 pub(crate) git_ref: String,
44}
45
46impl UsesRef {
47 pub(crate) fn full_name(&self) -> String {
48 format!("{}/{}", self.owner, self.repo)
49 }
50}
51
52/// `owner/repo[/path]@ref`, if `uses` is that. Local (`./…`) and
53/// `docker://` ones are not.
54pub(crate) fn parse_uses(uses: &str) -> Option<UsesRef> {
55 let uses = uses.trim();
56 if uses.starts_with("./") || uses.starts_with("docker://") {
57 return None;
58 }
59 let (name, git_ref) = uses.split_once('@')?;
60 let mut parts = name.splitn(3, '/');
61 let (owner, repo) = (parts.next()?, parts.next()?);
62 let path = parts.next().unwrap_or_default().trim_matches('/');
63 let fine = |part: &str| !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && part != "..";
64 if !fine(owner) || !fine(repo) || git_ref.trim().is_empty() || path.split('/').any(|part| part == "..") {
65 return None;
66 }
67 Some(UsesRef { owner: owner.to_owned(), repo: repo.to_owned(), path: path.to_owned(), git_ref: git_ref.trim().to_owned() })
68}
69
70/// Whether `caller`'s workflows may use `target`'s actions and reusable
71/// workflows, given `target`'s access level (`none` or `organization`).
72/// Err says why not.
73pub(crate) fn may_use(caller: &Repo, target: &Repo, access_level: &str) -> std::result::Result<(), String> {
74 if caller.id == target.id || !target.is_private {
75 return Ok(());
76 }
77 let name = format!("{}/{}", target.namespace, target.name);
78 if !caller.namespace.eq_ignore_ascii_case(&target.namespace) {
79 return Err(format!("{name} is private, and only repositories in {} may use its actions and workflows.", target.namespace));
80 }
81 if access_level != "organization" {
82 return Err(format!(
83 "{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."
84 ));
85 }
86 if !caller.is_private {
87 return Err(format!("{name} is private, and a public repository's workflows cannot use a private repository's actions or workflows."));
88 }
89 Ok(())
90}
91
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.92/// The owner that names g1t's own actions and workflows (`g1t/deploy`):
93/// a name nobody can register, an alias of the workspace that holds them
94/// (`flagon-io` on g1t.sh).
95pub(crate) const G1T_OWNER: &str = "g1t";
96
97/// Whether `uses:` names one of g1t's own actions or workflows, by the
98/// owner as written.
99pub(crate) fn names_g1ts_own(owner: &str) -> bool {
100 owner.eq_ignore_ascii_case(G1T_OWNER)
101}
102
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.103/// Whether `target` on g1t answers for its `owner/repo` name even when
104/// GitHub has a repository of the same name: when it is in the caller's own
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.105/// workspace, or is one of g1t's own (named under `g1t/`, as written in
106/// `owner`, which reaches the workspace `g1t` is an alias of) and public.
107/// A private one of g1t's own is for its own workspace, as any private
108/// repository is. Any other workspace's repository answers only for names
109/// GitHub doesn't publish.
110pub(crate) fn answers_for_its_name(caller: &Repo, owner: &str, target: &Repo) -> bool {
111 caller.namespace.eq_ignore_ascii_case(&target.namespace) || (names_g1ts_own(owner) && !target.is_private)
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.112}
113
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.114/// Whether an owner as written may be a workspace alias, and so is worth
115/// asking identity about: a namespace's shape in any case, as aliases have
116/// (identity reads it lowercased).
117pub(crate) fn may_be_alias(owner: &str) -> bool {
118 g1t_contracts::aliasable_name(owner).is_some()
119}
120
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts121/// Where another repository's action or workflow comes from.
122pub(crate) enum Found {
123 /// g1t has it, and the caller may use it.
124 G1t(Repo),
125 /// g1t does not have it (that the caller's workspace can see): GitHub.
126 GitHub,
127}
128
129impl Actions {
130 /// Looks for `owner/repo` on g1t, as `caller`'s workspace (`ws`) sees
131 /// it, and checks the caller may use it.
132 pub(crate) async fn find_used(&self, caller: &Repo, ws: &User, used: &UsesRef) -> Result<Outcome<Found>> {
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.133 let viewer = Some(ws.clone());
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts134 let path = RepoPath { namespace: used.owner.clone(), name: used.repo.clone() };
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.135 let mut found = self.visible_repo(&path, &viewer).await?;
136 // Nothing has the name as written: an alias (`g1t` for `flagon-io`)
137 // is looked for under its workspace's slug, as the same workspace
138 // sees it, so a private repository there stays out of sight.
139 if found.is_none()
140 && let Some(slug) = self.alias_of(&used.owner).await?
141 {
142 found = self.visible_repo(&RepoPath { namespace: slug, name: used.repo.clone() }, &viewer).await?;
143 }
144 let Some(target) = found else {
145 if names_g1ts_own(&used.owner) {
146 return Ok(fail(
147 FailureCode::NotFound,
148 format!("{} is not one of g1t's actions or workflows: g1t's own are public repositories under g1t/.", used.full_name()),
149 ));
150 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts151 return Ok(Outcome::Ok(Found::GitHub));
152 };
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.153 // A name GitHub publishes means GitHub's repository, unless the
154 // repository on g1t is the caller's own workspace's or g1t's: anyone
155 // can make a workspace called `actions`, and must not get to answer
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.156 // for `actions/setup-node`. A `g1t/` name is never GitHub's.
157 if !answers_for_its_name(caller, &used.owner, &target)
158 && !names_g1ts_own(&used.owner)
159 && self.github_publishes(&used.full_name()).await? != Some(false)
160 {
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.161 return Ok(Outcome::Ok(Found::GitHub));
162 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts163 let level = if target.is_private && target.id != caller.id { self.access_level_of(&target.id).await? } else { "none" };
164 Ok(match may_use(caller, &target, level) {
165 Ok(()) => Outcome::Ok(Found::G1t(target)),
166 Err(why) => fail(FailureCode::Forbidden, why),
167 })
168 }
169
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.170 /// The slug of the workspace `owner` is an alias of, if it is one.
171 async fn alias_of(&self, owner: &str) -> Result<Option<String>> {
172 if !may_be_alias(owner) {
173 return Ok(None);
174 }
175 let slug: Option<String> =
176 g1t_kit::call(&self.identity, "resolve_alias", &g1t_contracts::identity::SlugArgs { slug: owner.to_owned() }).await?;
177 Ok(slug.filter(|slug| !slug.eq_ignore_ascii_case(owner)))
178 }
179
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts180 /// `job_action`: a running job asks where to fetch an action from, by
181 /// `report.repository` (`owner/repo`) and `report.ref`. Answers
182 /// `{"source": "g1t", "url", "ref", "token"}` (a read-only token for
183 /// that repository, ending with the job, when it is private),
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'184 /// `{"source": "github", "sha", "archive", "note"}` (the commit its ref
185 /// names, and whether g1t keeps its archive; see registry.rs), or a
186 /// refusal saying why it may not be used.
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts187 pub async fn job_action(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<Outcome<Value>> {
188 let job = crate::check!(self.job_for_token(&a).await?);
189 let Some(run) = self.run_row(&job.run_id).await? else {
190 return Ok(fail(FailureCode::NotFound, "No such run."));
191 };
192 let repository = a.report["repository"].as_str().unwrap_or_default();
193 let git_ref = a.report["ref"].as_str().unwrap_or_default();
194 let Some(used) = parse_uses(&format!("{repository}@{git_ref}")) else {
195 return Ok(fail(FailureCode::Invalid, "Name the action's repository as owner/repo, and its ref."));
196 };
197 let Some((caller, ws)) = self.repo_by_id(&run.repo_id).await? else {
198 return Ok(fail(FailureCode::NotFound, "The repository is gone."));
199 };
200 let target = match crate::check!(self.find_used(&caller, &ws, &used).await?) {
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'201 Found::GitHub => return Ok(Outcome::Ok(self.github_action(&used).await?)),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts202 Found::G1t(target) => target,
203 };
204 let full_name = format!("{}/{}", target.namespace, target.name);
205 // A private repository is read with a token of its own: read-only,
206 // for that repository alone, ending with the job.
207 let token = if target.is_private {
208 let created: g1t_contracts::identity::CreatedAccessToken = g1t_kit::call(
209 &self.identity,
210 "create_job_token",
211 &g1t_contracts::identity::CreateJobTokenArgs {
212 workspace: ws.clone(),
213 repo: RepoPath { namespace: target.namespace.clone(), name: target.name.clone() },
214 run_id: run.id.clone(),
215 job_id: job.id.clone(),
216 name: format!("Action {full_name} for {} run {}", run.repo, run.number),
217 ttl_seconds: u64::from(job.timeout_minutes) * 60 + 600,
218 scopes: vec!["repo:read".to_owned(), "code:read".to_owned()],
219 pull_requests: false,
220 },
221 )
222 .await?;
223 Value::String(created.token)
224 } else {
225 Value::Null
226 };
227 Ok(Outcome::Ok(json!({
228 "source": "g1t",
229 "repository": full_name,
230 "url": format!("{SITE}/{full_name}.git"),
231 "ref": used.git_ref,
232 "token": token,
233 })))
234 }
235}
236
237/// Whether `path` is a workflow file: `.g1t/workflows/…` or
238/// `.github/workflows/…`, ending `.yml` or `.yaml`.
239pub(crate) fn is_workflow_path(path: &str) -> bool {
240 (path.starts_with(".g1t/workflows/") || path.starts_with(".github/workflows/")) && (path.ends_with(".yml") || path.ends_with(".yaml"))
241}
242
243/// The paths to try for a workflow file: as written, and for `.github/…`
244/// also `.g1t/…`, where a repository moved to g1t keeps it.
245fn candidates(path: &str) -> Vec<String> {
246 let mut paths = vec![path.to_owned()];
247 if let Some(rest) = path.strip_prefix(".github/") {
248 paths.push(format!(".g1t/{rest}"));
249 }
250 paths
251}
252
253/// What a job's `secrets:` passes to the workflow it calls, kept with the
254/// called jobs until they start, when it is read with the secrets
255/// themselves (`resolve_secrets`); no secret is stored. `inherit` passes
256/// all of the caller's; a mapping passes each name's expression, read with
257/// the caller's `secrets` and the contexts it had (`needs`, `inputs`,
258/// `matrix`); nothing passes none. `outer` is the caller's own, when the
259/// caller is itself a called workflow's job.
260pub(crate) fn secrets_plan(job_raw: &Value, contexts: &serde_json::Map<String, Value>, outer: Option<Value>) -> Value {
261 let mut plan = match job_raw.get("secrets") {
262 Some(Value::String(text)) if text.trim() == "inherit" => json!({ "inherit": true }),
263 Some(Value::Object(map)) => json!({
264 "map": map,
265 "scope": {
266 "needs": contexts.get("needs").cloned().unwrap_or_else(|| json!({})),
267 "inputs": contexts.get("inputs").cloned().unwrap_or_else(|| json!({})),
268 "matrix": contexts.get("matrix").cloned().unwrap_or_else(|| json!({})),
269 },
270 }),
271 _ => json!({ "map": {} }),
272 };
273 if let Some(outer) = outer.filter(Value::is_object) {
274 plan["outer"] = outer;
275 }
276 plan
277}
278
279/// The secrets a called workflow says are required
280/// (`on.workflow_call.secrets.<name>.required`) that `plan` does not pass.
281/// `inherit` passes whatever the caller has, so it is not checked here.
282pub(crate) fn missing_secrets(called_raw: &Value, plan: &Value) -> Vec<String> {
283 if plan["inherit"] == json!(true) {
284 return Vec::new();
285 }
286 let on = called_raw.get("on").or_else(|| called_raw.get("true")).cloned().unwrap_or(Value::Null);
287 let Some(Value::Object(declared)) = on.get("workflow_call").and_then(|call| call.get("secrets")).cloned() else {
288 return Vec::new();
289 };
290 let passed = plan["map"].as_object().cloned().unwrap_or_default();
291 declared
292 .iter()
293 .filter(|(_, spec)| spec.get("required").and_then(Value::as_bool) == Some(true))
294 .filter(|(name, _)| !passed.keys().any(|key| key.eq_ignore_ascii_case(name)))
295 .map(|(name, _)| name.clone())
296 .collect()
297}
298
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9299/// The secrets `plan` passes by name that the called workflow does not
300/// declare under `on.workflow_call.secrets`, which GitHub refuses.
301pub(crate) fn undeclared_secrets(called_raw: &Value, plan: &Value) -> Vec<String> {
302 let on = called_raw.get("on").or_else(|| called_raw.get("true")).cloned().unwrap_or(Value::Null);
303 let declared = on.get("workflow_call").and_then(|call| call.get("secrets")).and_then(Value::as_object).cloned().unwrap_or_default();
304 plan["map"]
305 .as_object()
306 .into_iter()
307 .flatten()
308 .map(|(name, _)| name)
309 .filter(|name| !declared.keys().any(|known| known.eq_ignore_ascii_case(name)))
310 .cloned()
311 .collect()
312}
313
314/// The inputs a called workflow gets, as GitHub reads them: `given` (the
315/// caller's `with:`, its expressions read) checked against `declared`
316/// (`on.workflow_call.inputs` of `file`). A name it does not declare is
317/// refused, and so is a value that is not of the input's `type`: a
318/// `boolean` takes `true` or `false`, a `number` a number (an output is
319/// text, so `fromJSON()` it), and a `string` text, a number or a boolean
320/// becoming its text. An input not given takes its `default`, else, unless
321/// it is `required`, `false`, `0` or `''` by its type.
322pub(crate) fn call_inputs(
323 file: &str,
324 declared: &serde_json::Map<String, Value>,
325 given: &serde_json::Map<String, Value>,
326) -> std::result::Result<serde_json::Map<String, Value>, String> {
327 if let Some(name) = given.keys().find(|name| !declared.keys().any(|known| known.eq_ignore_ascii_case(name))) {
328 return Err(format!("Invalid input: `{name}` is not defined in `{file}`."));
329 }
330 let mut inputs = serde_json::Map::new();
331 for (name, spec) in declared {
332 let kind = spec.get("type").and_then(Value::as_str).unwrap_or_default().trim().to_ascii_lowercase();
333 let passed = given.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value).filter(|value| !value.is_null());
334 let value = match (passed, spec.get("default").filter(|value| !value.is_null())) {
335 (Some(value), _) => typed(&kind, value).ok_or_else(|| {
336 let shown = match value {
337 Value::String(text) => format!("'{text}'"),
338 other => other.to_string(),
339 };
340 let hint = if value.is_string() { " An output is text: pass `${{ fromJSON(...) }}` for its value." } else { "" };
341 format!("The input `{name}` of `{file}` is a {kind}, and `with` gives it {shown}.{hint}")
342 })?,
343 // A default as written, a `'true'` for a boolean taken as true.
344 (None, Some(default)) => typed(&kind, default).or_else(|| loose(&kind, default)).unwrap_or_else(|| default.clone()),
345 (None, None) if spec.get("required").and_then(Value::as_bool) == Some(true) => {
346 return Err(format!("`{file}` needs the input `{name}`."));
347 }
348 (None, None) => match kind.as_str() {
349 "boolean" => Value::Bool(false),
350 "number" => json!(0),
351 "string" => Value::String(String::new()),
352 _ => Value::Null,
353 },
354 };
355 inputs.insert(name.clone(), value);
356 }
357 Ok(inputs)
358}
359
360/// `value` as an input of `kind`, if it is one.
361fn typed(kind: &str, value: &Value) -> Option<Value> {
362 match (kind, value) {
363 ("boolean", Value::Bool(_)) | ("number", Value::Number(_)) | ("string", Value::String(_)) => Some(value.clone()),
364 ("string", Value::Bool(_) | Value::Number(_)) => Some(Value::String(g1t_actions::expr::to_text(value))),
365 ("boolean" | "number" | "string", _) => None,
366 _ => Some(value.clone()),
367 }
368}
369
370/// A default written as text for a boolean or a number.
371fn loose(kind: &str, value: &Value) -> Option<Value> {
372 let text = value.as_str()?.trim();
373 match kind {
374 "boolean" if text.eq_ignore_ascii_case("true") => Some(Value::Bool(true)),
375 "boolean" if text.eq_ignore_ascii_case("false") => Some(Value::Bool(false)),
376 "number" => text.parse::<f64>().ok().and_then(serde_json::Number::from_f64).map(Value::Number),
377 _ => None,
378 }
379}
380
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts381/// The `secrets` a called workflow's job gets, by `plan` (`secrets_plan`),
382/// from `base` (the repository's secrets, as the top caller has them),
383/// with `github` and `vars` for the expressions. The job's token is added
384/// by the caller of this, as every job's is.
385pub(crate) fn resolve_secrets(
386 plan: &Value,
387 base: &serde_json::Map<String, Value>,
388 github: &Value,
389 vars: &serde_json::Map<String, Value>,
390) -> serde_json::Map<String, Value> {
391 let outer = match plan.get("outer").filter(|outer| outer.is_object()) {
392 Some(outer) => resolve_secrets(outer, base, github, vars),
393 None => base.clone(),
394 };
395 if plan["inherit"] == json!(true) {
396 return outer;
397 }
398 let mut contexts = serde_json::Map::new();
399 if let Some(Value::Object(scope)) = plan.get("scope") {
400 contexts.extend(scope.clone());
401 }
402 contexts.insert("secrets".into(), Value::Object(outer));
403 contexts.insert("github".into(), github.clone());
404 contexts.insert("vars".into(), Value::Object(vars.clone()));
405 let scope = g1t_actions::expr::Scope { contexts: &contexts, status: g1t_actions::expr::Status::Success, hash_files: None };
406 let mut passed = serde_json::Map::new();
407 for (name, expression) in plan["map"].as_object().into_iter().flatten() {
408 let value = g1t_actions::expr::interpolate_value(expression, &scope).unwrap_or(Value::Null);
409 let text = g1t_actions::expr::to_text(&value);
410 if !text.is_empty() {
411 passed.insert(name.clone(), Value::String(text));
412 }
413 }
414 passed
415}
416
417/// A public repository's file on GitHub, if it is there.
418async fn github_file(repository: &str, git_ref: &str, path: &str) -> Option<String> {
419 let url = format!("https://raw.githubusercontent.com/{repository}/{git_ref}/{path}");
420 let headers = worker::Headers::new();
421 headers.set("user-agent", "g1t-actions").ok()?;
422 let mut init = worker::RequestInit::new();
423 init.with_method(worker::Method::Get).with_headers(headers);
424 let request = worker::Request::new_with_init(&url, &init).ok()?;
425 let mut response = worker::Fetch::Request(request).send().await.ok()?;
426 if response.status_code() != 200 {
427 return None;
428 }
429 response.text().await.ok()
430}
431
432impl Actions {
433 /// The workflow file a job's `uses:` calls, its text, and where it
434 /// came from (`origin`, kept with its jobs so a `./` call inside it
435 /// reads from the same place): `{"source": "g1t" | "github", "repo",
436 /// "ref"}`. Err says why it cannot be called.
437 pub(crate) async fn called_workflow(
438 &self,
439 run: &crate::plan::RunRow,
440 row: &crate::plan::JobRow,
441 uses: &str,
442 ) -> Result<std::result::Result<(String, String, Value), String>> {
443 let Some(ws) = self.workspace_actor(&crate::repo_path(&run.repo).namespace).await? else {
444 return Ok(Err("The workspace is gone.".to_owned()));
445 };
446 let (origin, file) = match parse_uses(uses) {
447 None => {
448 let Some(local) = uses.trim().strip_prefix("./") else {
449 return Ok(Err(format!("`{uses}` is not a workflow: name one as ./.g1t/workflows/build.yml or owner/repo/.g1t/workflows/build.yml@ref.")));
450 };
451 // In the same repository and commit as the workflow the
452 // calling job is in: the run's, or the called workflow's.
453 let origin = row
454 .call()
455 .filter(|call| call["role"] == "callee")
456 .and_then(|call| call.get("origin").cloned())
457 .filter(Value::is_object)
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'458 .unwrap_or_else(|| json!({ "source": "g1t", "repo": run.repo, "ref": run.info().sha }));
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts459 (origin, local.split('@').next().unwrap_or(local).to_owned())
460 }
461 Some(used) => {
462 if !is_workflow_path(&used.path) {
463 return Ok(Err(format!("`{uses}` is not a workflow file: it is under .g1t/workflows/ or .github/workflows/ and ends .yml or .yaml.")));
464 }
465 let Some((caller, _)) = self.repo_by_id(&run.repo_id).await? else {
466 return Ok(Err("The repository is gone.".to_owned()));
467 };
468 let origin = match self.find_used(&caller, &ws, &used).await? {
469 Outcome::Fail(refused) => return Ok(Err(refused.message)),
470 Outcome::Ok(Found::G1t(target)) => {
471 json!({ "source": "g1t", "repo": format!("{}/{}", target.namespace, target.name), "ref": used.git_ref })
472 }
473 Outcome::Ok(Found::GitHub) => json!({ "source": "github", "repo": used.full_name(), "ref": used.git_ref }),
474 };
475 (origin, used.path)
476 }
477 };
478 let repository = origin["repo"].as_str().unwrap_or_default().to_owned();
479 let git_ref = origin["ref"].as_str().unwrap_or_default().to_owned();
480 let on_github = origin["source"] == "github";
481 for path in candidates(&file) {
482 let text = if on_github {
483 github_file(&repository, &git_ref, &path).await
484 } else {
485 self.read_file(&crate::repo_path(&repository), &ws, &git_ref, &path).await?
486 };
487 if let Some(text) = text {
488 // Shown as the repository names it, when it is another's.
489 let shown = if repository.eq_ignore_ascii_case(&run.repo) { path } else { format!("{repository}/{path}@{git_ref}") };
490 return Ok(Ok((shown, text, origin)));
491 }
492 }
493 Ok(Err(if repository.eq_ignore_ascii_case(&run.repo) {
494 format!("`{uses}` is not in the repository at this commit.")
495 } else if on_github {
496 format!("`{uses}` was found neither on g1t nor in a public repository on GitHub.")
497 } else {
498 format!("`{uses}`: {repository} has no {file} at {git_ref}.")
499 }))
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn a_called_workflow_gets_only_the_secrets_passed_to_it() {
509 let base: serde_json::Map<String, Value> =
510 serde_json::from_value(json!({ "NPM_TOKEN": "npm-1", "DEPLOY_KEY": "key-2", "OTHER": "x" })).unwrap();
511 let github = json!({ "ref": "refs/heads/main" });
512 let vars = serde_json::Map::new();
513 let contexts: serde_json::Map<String, Value> = serde_json::from_value(json!({ "needs": { "build": { "outputs": { "target": "prod" } } } })).unwrap();
514 // Nothing passed: nothing but the job's token, which is added later.
515 let none = secrets_plan(&json!({ "uses": "acme/shared/.g1t/workflows/x.yml@v1" }), &contexts, None);
516 assert!(resolve_secrets(&none, &base, &github, &vars).is_empty());
517 // inherit: all of them.
518 let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, None);
519 assert_eq!(resolve_secrets(&inherit, &base, &github, &vars), base);
520 // A mapping: each name's expression, read with the caller's secrets
521 // and contexts.
522 let mapped = secrets_plan(
523 &json!({ "secrets": { "token": "${{ secrets.NPM_TOKEN }}", "where": "${{ needs.build.outputs.target }}-${{ secrets.DEPLOY_KEY }}" } }),
524 &contexts,
525 None,
526 );
527 let passed = resolve_secrets(&mapped, &base, &github, &vars);
528 assert_eq!(passed.len(), 2);
529 assert_eq!(passed["token"], "npm-1");
530 assert_eq!(passed["where"], "prod-key-2");
531 // Nested: the inner call reads what the outer one was given.
532 let inner = secrets_plan(&json!({ "secrets": { "NPM": "${{ secrets.token }}", "LEAK": "${{ secrets.OTHER }}" } }), &contexts, Some(mapped.clone()));
533 let passed = resolve_secrets(&inner, &base, &github, &vars);
534 assert_eq!(passed.get("NPM"), Some(&json!("npm-1")));
535 assert_eq!(passed.get("LEAK"), None);
536 let inherited = secrets_plan(&json!({ "secrets": "inherit" }), &contexts, Some(mapped));
537 assert_eq!(resolve_secrets(&inherited, &base, &github, &vars).len(), 2);
538 }
539
540 #[test]
541 fn required_secrets_must_be_passed() {
542 let called = g1t_actions::workflow::parse(
543 "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' }]",
544 )
545 .unwrap()
546 .raw;
547 let none = secrets_plan(&json!({}), &serde_json::Map::new(), None);
548 assert_eq!(missing_secrets(&called, &none), ["token"]);
549 let passed = secrets_plan(&json!({ "secrets": { "TOKEN": "${{ secrets.X }}" } }), &serde_json::Map::new(), None);
550 assert!(missing_secrets(&called, &passed).is_empty());
551 let inherit = secrets_plan(&json!({ "secrets": "inherit" }), &serde_json::Map::new(), None);
552 assert!(missing_secrets(&called, &inherit).is_empty());
553 }
554
555 #[test]
Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9556 fn a_called_workflows_inputs_are_checked_against_their_types() {
557 let declared: serde_json::Map<String, Value> = serde_json::from_value(json!({
558 "flag": { "type": "boolean" },
559 "count": { "type": "number", "default": 2 },
560 "name": { "type": "string", "required": true },
561 "dry": { "type": "boolean", "default": "true" },
562 }))
563 .unwrap();
564 let given = |value: Value| -> serde_json::Map<String, Value> { serde_json::from_value(value).unwrap() };
565 let file = ".g1t/workflows/build.yml";
566 // A string input takes a number as its text; what is not given takes
567 // its default, else false, 0 or '' by its type.
568 let inputs = call_inputs(file, &declared, &given(json!({ "name": 18 }))).unwrap();
569 assert_eq!(inputs["name"], json!("18"));
570 assert_eq!(inputs["flag"], json!(false));
571 assert_eq!(inputs["count"], json!(2));
572 assert_eq!(inputs["dry"], json!(true));
573 let inputs = call_inputs(file, &declared, &given(json!({ "NAME": "x", "flag": true, "count": 3.5 }))).unwrap();
574 assert_eq!((inputs["name"].clone(), inputs["flag"].clone(), inputs["count"].clone()), (json!("x"), json!(true), json!(3.5)));
575 // Text where a boolean or a number is declared is refused, as on GitHub.
576 let wrong = call_inputs(file, &declared, &given(json!({ "name": "x", "flag": "true" }))).unwrap_err();
577 assert!(wrong.contains("`flag`") && wrong.contains("boolean") && wrong.contains("fromJSON"), "{wrong}");
578 assert!(call_inputs(file, &declared, &given(json!({ "name": "x", "count": "3" }))).unwrap_err().contains("number"));
579 assert!(call_inputs(file, &declared, &given(json!({ "name": ["a"] }))).unwrap_err().contains("string"));
580 // A required input left out, and one the workflow does not declare.
581 assert_eq!(call_inputs(file, &declared, &given(json!({}))).unwrap_err(), "`.g1t/workflows/build.yml` needs the input `name`.");
582 assert_eq!(
583 call_inputs(file, &declared, &given(json!({ "name": "x", "extra": 1 }))).unwrap_err(),
584 "Invalid input: `extra` is not defined in `.g1t/workflows/build.yml`."
585 );
586 }
587
588 #[test]
589 fn secrets_a_called_workflow_does_not_declare_are_refused() {
590 let called = g1t_actions::workflow::parse(
591 "on:\n workflow_call:\n secrets:\n token: { required: false }\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]",
592 )
593 .unwrap()
594 .raw;
595 let plan = secrets_plan(&json!({ "secrets": { "TOKEN": "x", "other": "y" } }), &serde_json::Map::new(), None);
596 assert_eq!(undeclared_secrets(&called, &plan), ["other"]);
597 assert!(undeclared_secrets(&called, &secrets_plan(&json!({ "secrets": "inherit" }), &serde_json::Map::new(), None)).is_empty());
598 }
599
600 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts601 fn only_workflow_files_are_called() {
602 assert!(is_workflow_path(".g1t/workflows/build.yml"));
603 assert!(is_workflow_path(".github/workflows/build.yaml"));
604 assert!(!is_workflow_path("actions/setup/action.yml"));
605 assert!(!is_workflow_path(".github/workflows/notes.md"));
606 assert_eq!(candidates(".github/workflows/x.yml"), [".github/workflows/x.yml", ".g1t/workflows/x.yml"]);
607 assert_eq!(candidates(".g1t/workflows/x.yml"), [".g1t/workflows/x.yml"]);
608 }
609
610 fn repo(id: &str, namespace: &str, private: bool) -> Repo {
611 serde_json::from_value(json!({
612 "id": id, "namespace": namespace, "name": id, "description": null, "isPrivate": private,
613 "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
614 }))
615 .unwrap()
616 }
617
618 #[test]
619 fn uses_names_a_repository_a_path_and_a_ref() {
620 assert_eq!(
621 parse_uses("acme/shared/.g1t/workflows/build.yml@v2"),
622 Some(UsesRef { owner: "acme".into(), repo: "shared".into(), path: ".g1t/workflows/build.yml".into(), git_ref: "v2".into() })
623 );
624 assert_eq!(parse_uses("acme/setup@main").map(|u| (u.path, u.git_ref)), Some((String::new(), "main".into())));
625 assert_eq!(parse_uses("./.g1t/workflows/build.yml"), None);
626 assert_eq!(parse_uses("docker://alpine:3"), None);
627 assert_eq!(parse_uses("acme/setup"), None);
628 assert_eq!(parse_uses("acme/../x@v1"), None);
629 assert_eq!(parse_uses("acme/shared/../../etc@v1"), None);
630 }
631
632 #[test]
633 fn who_may_use_a_repositorys_actions() {
634 let web = repo("web", "acme", true);
635 // Itself, and anything public, always.
636 assert!(may_use(&web, &web, "none").is_ok());
637 assert!(may_use(&web, &repo("lint", "other", false), "none").is_ok());
638 // A private one: only when it allows its workspace's repositories.
639 let shared = repo("shared", "acme", true);
640 assert!(may_use(&web, &shared, "none").unwrap_err().contains("Settings, Actions, Access"));
641 assert!(may_use(&web, &shared, "organization").is_ok());
642 // Never from another workspace, nor from a public repository.
643 assert!(may_use(&repo("x", "other", true), &shared, "organization").unwrap_err().contains("only repositories in acme"));
644 assert!(may_use(&repo("site", "acme", false), &shared, "organization").unwrap_err().contains("public repository"));
645 }
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.646
647 #[test]
648 fn only_its_own_workspace_or_g1t_answers_for_a_name_github_has() {
649 let web = repo("web", "acme", true);
650 // The caller's own workspace, and g1t's own actions, whatever GitHub has.
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.651 assert!(answers_for_its_name(&web, "acme", &repo("setup", "acme", true)));
652 assert!(answers_for_its_name(&web, "acme", &repo("setup", "ACME", false)));
653 assert!(answers_for_its_name(&web, "g1t", &repo("deploy", "g1t", false)));
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.654 // Anyone else's public repository named like GitHub's `actions/setup-node`
655 // does not: GitHub's is used when GitHub has one.
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.656 assert!(!answers_for_its_name(&web, "actions", &repo("setup-node", "actions", false)));
657 }
658
659 /// `g1t/deploy` is found as `flagon-io/deploy`, `g1t` being its alias.
660 #[test]
661 fn g1t_answers_through_its_alias_for_public_repositories_only() {
662 let web = repo("web", "acme", true);
663 let deploy = repo("deploy", "flagon-io", false);
664 let internal = repo("internal", "flagon-io", true);
665 // Written as g1t/deploy: g1t's own, public, answers for its name.
666 assert!(answers_for_its_name(&web, "g1t", &deploy));
667 assert!(answers_for_its_name(&web, "G1T", &deploy));
668 // A private repository of the same workspace never does for another
669 // workspace, and no other workspace may use it either way.
670 assert!(!answers_for_its_name(&web, "g1t", &internal));
671 assert!(may_use(&web, &internal, "organization").unwrap_err().contains("only repositories in flagon-io"));
672 assert!(may_use(&repo("site", "acme", false), &internal, "none").is_err());
673 // Named under flagon-io as written, it is only another workspace's.
674 assert!(!answers_for_its_name(&web, "flagon-io", &deploy));
675 // flagon-io's own repositories use theirs as their own.
676 let own = repo("g1t", "flagon-io", true);
677 assert!(answers_for_its_name(&own, "g1t", &internal));
678 assert!(may_use(&own, &internal, "organization").is_ok());
679 // A public one of g1t's own is used from anywhere, public or private.
680 assert!(may_use(&web, &deploy, "none").is_ok());
681 assert!(may_use(&repo("site", "acme", false), &deploy, "none").is_ok());
682 }
683
684 #[test]
685 fn only_namespace_shaped_owners_are_asked_about_as_aliases() {
686 assert!(names_g1ts_own("g1t") && names_g1ts_own("G1t") && !names_g1ts_own("flagon-io"));
687 assert!(may_be_alias("g1t"));
688 assert!(may_be_alias("flagon") && may_be_alias("G1T"));
689 for owner in ["some_org", "a.b", "-x", "x--y", "settings"] {
690 assert!(!may_be_alias(owner), "{owner}");
691 }
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.692 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts693}

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