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 timeouts | 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. | |
| 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 timeouts | 27 | |
| 28 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 29 | use g1t_contracts::{FailureCode, Outcome, User}; | |
| 30 | use serde_json::{Value, json}; | |
| 31 | use worker::Result; | |
| 32 | ||
| 33 | use crate::{Actions, SITE, fail}; | |
| 34 | ||
| 35 | /// What `uses:` names in another repository. | |
| 36 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 37 | pub(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 | ||
| 46 | impl 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. | |
| 54 | pub(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. | |
| 73 | pub(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). | |
| 95 | pub(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. | |
| 99 | pub(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. | |
| 110 | pub(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). | |
| 117 | pub(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 timeouts | 121 | /// Where another repository's action or workflow comes from. |
| 122 | pub(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 | ||
| 129 | impl 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 timeouts | 134 | 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 timeouts | 151 | 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 timeouts | 163 | 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 timeouts | 180 | /// `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 timeouts | 187 | 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 timeouts | 202 | 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`. | |
| 239 | pub(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. | |
| 245 | fn 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. | |
| 260 | pub(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. | |
| 282 | pub(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-7nm8r9 | 299 | /// The secrets `plan` passes by name that the called workflow does not |
| 300 | /// declare under `on.workflow_call.secrets`, which GitHub refuses. | |
| 301 | pub(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. | |
| 322 | pub(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. | |
| 361 | fn 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. | |
| 371 | fn 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 timeouts | 381 | /// 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. | |
| 385 | pub(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. | |
| 418 | async 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 | ||
| 432 | impl 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 timeouts | 459 | (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)] | |
| 504 | mod 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-7nm8r9 | 556 | 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 timeouts | 601 | 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 timeouts | 693 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.