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.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
11 files+219−130/11 viewed
| 126 | 126 | ||
| 127 | 127 | A step's `uses: owner/repo@ref` (or `owner/repo/path@ref`) and a job's | |
| 128 | 128 | `uses: owner/repo/.g1t/workflows/build.yml@ref` name another repository. | |
| 129 | − | g1t looks for it on g1t first: | |
| 129 | + | g1t looks for it on g1t first, but a name means what it means on GitHub | |
| 130 | + | whenever GitHub has it: | |
| 130 | 131 | ||
| 131 | 132 | | The repository | What happens | | |
| 132 | 133 | | --- | --- | | |
| 133 | − | | On g1t and public | Your workflows use it, from any workspace. | | |
| 134 | + | | On g1t and public: in your workspace, under `g1t/`, or with a name GitHub has no public repository of | Your workflows use it, from any workspace. | | |
| 134 | 135 | | On g1t, private, in your workspace, with **Access** set to *Accessible from repositories in* the workspace | Your private repositories' workflows use it. A job reads it with a read-only token for that repository alone, which ends with the job. | | |
| 135 | 136 | | On g1t, private, and not shared that way | The step or job fails, and says why. A private repository's actions are never used by a public repository's workflows, whose logs anyone can read, nor from another workspace. | | |
| 136 | 137 | | Not on g1t, or private in a workspace you cannot see | An action comes from GitHub, at the commit its ref names there (see [actions from GitHub](#actions-from-github)); a reusable workflow is read from a public repository on GitHub. | | |
| 138 | + | | On g1t, public, in another workspace, with a name GitHub has a public repository of | GitHub's is used, as on GitHub: anyone can make a workspace, so a workspace called `actions` never answers for `actions/setup-node`. When g1t can't reach GitHub to tell, GitHub's is used too. | | |
| 137 | 139 | ||
| 138 | 140 | `ref` is a branch, a tag or a commit. A reusable workflow may be under | |
| 139 | 141 | `.g1t/workflows/` or `.github/workflows/`; a `.github/workflows/` path |
| 17 | 17 | be registered by anyone else, signing in works in any case, | |
| 18 | 18 | `g1t.sh/u/ANA-LOPEZ` opens the same profile, and an `@ana-lopez` mention | |
| 19 | 19 | reaches you. Addresses and git URLs use the lowercase form. Reserved words | |
| 20 | − | (such as `settings`, `api` or `g1t`) are reserved in every case. | |
| 20 | + | (such as `settings`, `api` or `g1t`) are reserved in every case. So are | |
| 21 | + | the names of the GitHub accounts whose actions workflows use most, such as | |
| 22 | + | `actions`, `docker` and `aws-actions`, so that nobody on g1t is mistaken | |
| 23 | + | for them; see [actions from other repositories](/guides/actions/#actions-and-workflows-from-other-repositories). | |
| 24 | + | Signing up with a GitHub account of one of those names suggests the name | |
| 25 | + | with `-gh` added. | |
| 21 | 26 | ||
| 22 | 27 | Before you can do anything else, you [confirm your email | |
| 23 | 28 | address](#confirming-your-email-address) with the code g1t emails you, |
| 154 | 154 | register a username with the old name. Links and remotes that still use | |
| 155 | 155 | it then reach whatever has the name, or nothing. | |
| 156 | 156 | - The new name follows the same rules as a new workspace: lowercase letters, | |
| 157 | − | digits and single hyphens, up to 39 characters, not a reserved word, and | |
| 157 | + | digits and single hyphens, up to 39 characters, not a reserved word or a | |
| 158 | + | well-known GitHub action owner's name (such as `actions` or `docker`), and | |
| 158 | 159 | not another workspace's slug or someone else's username. | |
| 159 | 160 | ||
| 160 | 161 | ## Data residency |
| 51 | 51 | ||
| 52 | 52 | pub use ids::{id_floor, new_id}; | |
| 53 | 53 | pub use names::{ | |
| 54 | − | Username, aliasable_name, claimable_namespace, claimable_username, is_namespace_shaped, is_reserved_name, is_route_name, | |
| 54 | + | Username, aliasable_name, claimable_namespace, is_action_owner_name, is_claimable_name, claimable_username, is_namespace_shaped, is_reserved_name, is_route_name, | |
| 55 | 55 | is_valid_namespace, is_valid_repo_name, | |
| 56 | 56 | }; | |
| 57 | 57 | pub use outcome::{Failure, FailureCode, Outcome}; |
| 57 | 57 | /// `aliases.rs`). | |
| 58 | 58 | const OWN: &[&str] = &["g1t", "g1t-agent", "ghost"]; | |
| 59 | 59 | ||
| 60 | + | /// The GitHub accounts whose actions workflows use most. A workflow's | |
| 61 | + | /// `uses: actions/setup-node@v4` means GitHub's `actions`, so nobody may | |
| 62 | + | /// register these on g1t and be mistaken for them. They are not reserved | |
| 63 | + | /// as [`is_reserved_name`] is: an account that already has one keeps it, | |
| 64 | + | /// mentions and invitations still find it, and staff can give one, as an | |
| 65 | + | /// alias, to the account that proves it owns the name on GitHub. | |
| 66 | + | const ACTION_OWNERS: &[&str] = &[ | |
| 67 | + | "actions", "actions-ecosystem", "actions-rs", "amannn", "anchore", "appleboy", "aquasecurity", | |
| 68 | + | "arduino", "astral-sh", "aws-actions", "azure", "browser-actions", "bufbuild", "cachix", | |
| 69 | + | "changesets", "chromaui", "cloudflare", "codecov", "conda-incubator", "coursier", "crazy-max", | |
| 70 | + | "cypress-io", "dawidd6", "denoland", "dependabot", "dessant", "determinatesystems", | |
| 71 | + | "devcontainers", "digitalocean", "docker", "dorny", "dtolnay", "endbug", "erlef", | |
| 72 | + | "firebaseextended", "fluxcd", "getsentry", "github", "golangci", "google-github-actions", | |
| 73 | + | "googleapis", "goreleaser", "gradle", "gradle-update", "hadolint", "hashicorp", | |
| 74 | + | "haskell-actions", "hecrj", "helm", "ilammy", "jamesives", "jetbrains", "julia-actions", | |
| 75 | + | "kubernetes-sigs", "lukka", "marocchino", "microsoft", "mikepenz", "mozilla-actions", "msys2", | |
| 76 | + | "mxschmitt", "ncipollo", "netlify", "nick-fields", "nwtgck", "octokit", "ossf", "oven-sh", | |
| 77 | + | "peaceiris", "peter-evans", "pnpm", "pre-commit", "pypa", "r-lib", "release-drafter", | |
| 78 | + | "renovatebot", "reviewdog", "rhysd", "ruby", "rust-lang", "sbt", "shivammathur", "sigstore", | |
| 79 | + | "slackapi", "slsa-framework", "snyk", "softprops", "sonarsource", "stefanzweifel", | |
| 80 | + | "step-security", "styfle", "subosito", "supabase", "super-linter", "superfly", "svenstaro", | |
| 81 | + | "swatinem", "taiki-e", "tj-actions", "treosh", "vercel", "webfactory", | |
| 82 | + | ]; | |
| 83 | + | ||
| 60 | 84 | /// Whether `value`, whatever its case, is a name nobody can register or | |
| 61 | 85 | /// rename a workspace to: a route, or g1t's own. | |
| 62 | 86 | pub fn is_reserved_name(value: &str) -> bool { | |
| 63 | 87 | is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim())) | |
| 64 | 88 | } | |
| 65 | 89 | ||
| 90 | + | /// Whether `value`, whatever its case, names one of the GitHub accounts | |
| 91 | + | /// whose actions workflows use most: see [`ACTION_OWNERS`]. | |
| 92 | + | pub fn is_action_owner_name(value: &str) -> bool { | |
| 93 | + | ACTION_OWNERS.iter().any(|owner| owner.eq_ignore_ascii_case(value.trim())) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Whether a person or workspace may take `value` as its name now, by | |
| 97 | + | /// signing up, creating a workspace or renaming one: a valid namespace | |
| 98 | + | /// that no well-known GitHub action owner goes by. | |
| 99 | + | pub fn is_claimable_name(value: &str) -> bool { | |
| 100 | + | is_valid_namespace(value) && !is_action_owner_name(value) | |
| 101 | + | } | |
| 102 | + | ||
| 66 | 103 | /// Whether `value`, whatever its case, is one of the site's own routes. | |
| 67 | 104 | pub fn is_route_name(value: &str) -> bool { | |
| 68 | 105 | let value = value.trim(); | |
| ⋯ | |||
| 99 | 136 | /// reserved. | |
| 100 | 137 | pub fn claimable_namespace(value: &str) -> Option<String> { | |
| 101 | 138 | let value = value.trim().to_lowercase(); | |
| 102 | − | is_valid_namespace(&value).then_some(value) | |
| 139 | + | is_claimable_name(&value).then_some(value) | |
| 103 | 140 | } | |
| 104 | 141 | ||
| 105 | 142 | /// A username as someone typed it, if it can be registered: the name | |
| ⋯ | |||
| 114 | 151 | return None; | |
| 115 | 152 | } | |
| 116 | 153 | let canonical = display.to_ascii_lowercase(); | |
| 117 | − | is_valid_namespace(&canonical).then(|| Username { canonical, display: display.to_owned() }) | |
| 154 | + | is_claimable_name(&canonical).then(|| Username { canonical, display: display.to_owned() }) | |
| 118 | 155 | } | |
| 119 | 156 | ||
| 120 | 157 | /// A username in its two forms: see [`claimable_username`]. | |
| ⋯ | |||
| 169 | 206 | } | |
| 170 | 207 | ||
| 171 | 208 | #[test] | |
| 209 | + | fn github_action_owners_cannot_be_claimed_but_stay_findable() { | |
| 210 | + | for name in ["actions", "Actions", " docker ", "aws-actions", "Swatinem"] { | |
| 211 | + | assert!(is_action_owner_name(name), "{name}"); | |
| 212 | + | assert_eq!(claimable_namespace(name), None, "{name}"); | |
| 213 | + | assert_eq!(claimable_username(name), None, "{name}"); | |
| 214 | + | } | |
| 215 | + | // An account that has one already is still a valid name to find, | |
| 216 | + | // mention and invite, can be given one as an alias, and is not | |
| 217 | + | // protected the way g1t's own names are. | |
| 218 | + | assert!(is_valid_namespace("actions")); | |
| 219 | + | assert!(!is_reserved_name("actions")); | |
| 220 | + | assert_eq!(aliasable_name("Actions").as_deref(), Some("actions")); | |
| 221 | + | assert!(!is_action_owner_name("actions-team")); | |
| 222 | + | for owner in ACTION_OWNERS { | |
| 223 | + | assert!(is_namespace_shaped(owner) && *owner == owner.to_ascii_lowercase(), "{owner}"); | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[test] | |
| 172 | 228 | fn names_near_g1ts_are_anyones() { | |
| 173 | 229 | assert!(!is_reserved_name("g1t-agents")); | |
| 174 | 230 | assert_eq!(claimable_namespace("G1T-Bot").as_deref(), Some("g1t-bot")); | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { USERNAME_PATTERN, canonicalUsername, isReservedName, isValidNamespace, isValidUsername, shownUsername } from "./names.ts"; | |
| 4 | + | import { USERNAME_PATTERN, canonicalUsername, isActionOwnerName, isClaimableName, isReservedName, isValidNamespace, isValidUsername, shownUsername } from "./names.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("usernames take letters of either case, digits and single hyphens, up to 39", () => { | |
| 7 | 7 | for (const good of ["ana", "Ana", "ANA-Lopez", "a", "A1", "x".repeat(39)]) assert.ok(isValidUsername(good), good); | |
| ⋯ | |||
| 31 | 31 | for (const name of ["Ana", "ana-lopez", "A1"]) assert.ok(pattern.test(name), name); | |
| 32 | 32 | for (const name of ["-ana", "ana-", "an--a", "an_a"]) assert.ok(!pattern.test(name), name); | |
| 33 | 33 | }); | |
| 34 | + | ||
| 35 | + | test("well-known GitHub action owners cannot be claimed, but stay valid names", () => { | |
| 36 | + | for (const name of ["actions", "Docker", " aws-actions "]) { | |
| 37 | + | assert.ok(isActionOwnerName(name), name); | |
| 38 | + | assert.ok(!isValidUsername(name), name); | |
| 39 | + | } | |
| 40 | + | assert.ok(!isClaimableName("actions")); | |
| 41 | + | assert.ok(isValidNamespace("actions")); | |
| 42 | + | assert.ok(isClaimableName("actions-team")); | |
| 43 | + | }); | |
| 25 | 25 | "download", "downloads", | |
| 26 | 26 | ]); | |
| 27 | 27 | ||
| 28 | + | /** | |
| 29 | + | * The GitHub accounts whose actions workflows use most. `uses: | |
| 30 | + | * actions/setup-node@v4` means GitHub's `actions`, so nobody may register | |
| 31 | + | * these on g1t; an account that already has one keeps it. The list is | |
| 32 | + | * crates/contracts/src/names.rs's `ACTION_OWNERS`. | |
| 33 | + | */ | |
| 34 | + | const ACTION_OWNERS = new Set([ | |
| 35 | + | "actions", "actions-ecosystem", "actions-rs", "amannn", "anchore", "appleboy", "aquasecurity", | |
| 36 | + | "arduino", "astral-sh", "aws-actions", "azure", "browser-actions", "bufbuild", "cachix", | |
| 37 | + | "changesets", "chromaui", "cloudflare", "codecov", "conda-incubator", "coursier", "crazy-max", | |
| 38 | + | "cypress-io", "dawidd6", "denoland", "dependabot", "dessant", "determinatesystems", | |
| 39 | + | "devcontainers", "digitalocean", "docker", "dorny", "dtolnay", "endbug", "erlef", | |
| 40 | + | "firebaseextended", "fluxcd", "getsentry", "github", "golangci", "google-github-actions", | |
| 41 | + | "googleapis", "goreleaser", "gradle", "gradle-update", "hadolint", "hashicorp", | |
| 42 | + | "haskell-actions", "hecrj", "helm", "ilammy", "jamesives", "jetbrains", "julia-actions", | |
| 43 | + | "kubernetes-sigs", "lukka", "marocchino", "microsoft", "mikepenz", "mozilla-actions", "msys2", | |
| 44 | + | "mxschmitt", "ncipollo", "netlify", "nick-fields", "nwtgck", "octokit", "ossf", "oven-sh", | |
| 45 | + | "peaceiris", "peter-evans", "pnpm", "pre-commit", "pypa", "r-lib", "release-drafter", | |
| 46 | + | "renovatebot", "reviewdog", "rhysd", "ruby", "rust-lang", "sbt", "shivammathur", "sigstore", | |
| 47 | + | "slackapi", "slsa-framework", "snyk", "softprops", "sonarsource", "stefanzweifel", | |
| 48 | + | "step-security", "styfle", "subosito", "supabase", "super-linter", "superfly", "svenstaro", | |
| 49 | + | "swatinem", "taiki-e", "tj-actions", "treosh", "vercel", "webfactory", | |
| 50 | + | ]); | |
| 51 | + | ||
| 52 | + | /** Whether `value`, whatever its case, names a well-known GitHub action owner. */ | |
| 53 | + | export function isActionOwnerName(value: string): boolean { | |
| 54 | + | return ACTION_OWNERS.has(value.trim().toLowerCase()); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** Whether a person or workspace may take `value` as its name now: valid, and no well-known action owner's. */ | |
| 58 | + | export function isClaimableName(value: string): boolean { | |
| 59 | + | return isValidNamespace(value) && !isActionOwnerName(value); | |
| 60 | + | } | |
| 61 | + | ||
| 28 | 62 | /** Whether `value`, whatever its case, is a name nobody can register: a route, or g1t's own. */ | |
| 29 | 63 | export function isReservedName(value: string): boolean { | |
| 30 | 64 | return RESERVED.has(value.trim().toLowerCase()); | |
| ⋯ | |||
| 55 | 89 | ||
| 56 | 90 | export function isValidUsername(value: string): boolean { | |
| 57 | 91 | const name = value.trim(); | |
| 58 | − | return USERNAME.test(name) && !isReservedName(name); | |
| 92 | + | return USERNAME.test(name) && !isReservedName(name) && !isActionOwnerName(name); | |
| 59 | 93 | } | |
| 60 | 94 | ||
| 61 | 95 | /** The name a person is found, linked and mentioned by: lowercased. */ | |
| 75 | 75 | Ok(()) | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | /// Whether `target` on g1t answers for its `owner/repo` name even when | |
| 79 | + | /// GitHub has a repository of the same name: when it is in the caller's own | |
| 80 | + | /// workspace, or is one of g1t's own (`g1t` is a name nobody can register). | |
| 81 | + | /// Any other workspace's repository answers only for names GitHub doesn't | |
| 82 | + | /// publish. | |
| 83 | + | pub(crate) fn answers_for_its_name(caller: &Repo, target: &Repo) -> bool { | |
| 84 | + | caller.namespace.eq_ignore_ascii_case(&target.namespace) || target.namespace.eq_ignore_ascii_case("g1t") | |
| 85 | + | } | |
| 86 | + | ||
| 78 | 87 | /// Where another repository's action or workflow comes from. | |
| 79 | 88 | pub(crate) enum Found { | |
| 80 | 89 | /// g1t has it, and the caller may use it. | |
| ⋯ | |||
| 91 | 100 | let Some(target) = self.visible_repo(&path, &Some(ws.clone())).await? else { | |
| 92 | 101 | return Ok(Outcome::Ok(Found::GitHub)); | |
| 93 | 102 | }; | |
| 103 | + | // A name GitHub publishes means GitHub's repository, unless the | |
| 104 | + | // repository on g1t is the caller's own workspace's or g1t's: anyone | |
| 105 | + | // can make a workspace called `actions`, and must not get to answer | |
| 106 | + | // for `actions/setup-node`. | |
| 107 | + | if !answers_for_its_name(caller, &target) && self.github_publishes(&used.full_name()).await? != Some(false) { | |
| 108 | + | return Ok(Outcome::Ok(Found::GitHub)); | |
| 109 | + | } | |
| 94 | 110 | let level = if target.is_private && target.id != caller.id { self.access_level_of(&target.id).await? } else { "none" }; | |
| 95 | 111 | Ok(match may_use(caller, &target, level) { | |
| 96 | 112 | Ok(()) => Outcome::Ok(Found::G1t(target)), | |
| ⋯ | |||
| 437 | 453 | assert!(may_use(&repo("x", "other", true), &shared, "organization").unwrap_err().contains("only repositories in acme")); | |
| 438 | 454 | assert!(may_use(&repo("site", "acme", false), &shared, "organization").unwrap_err().contains("public repository")); | |
| 439 | 455 | } | |
| 456 | + | ||
| 457 | + | #[test] | |
| 458 | + | fn only_its_own_workspace_or_g1t_answers_for_a_name_github_has() { | |
| 459 | + | let web = repo("web", "acme", true); | |
| 460 | + | // The caller's own workspace, and g1t's own actions, whatever GitHub has. | |
| 461 | + | assert!(answers_for_its_name(&web, &repo("setup", "acme", true))); | |
| 462 | + | assert!(answers_for_its_name(&web, &repo("setup", "ACME", false))); | |
| 463 | + | assert!(answers_for_its_name(&web, &repo("deploy", "g1t", false))); | |
| 464 | + | // Anyone else's public repository named like GitHub's `actions/setup-node` | |
| 465 | + | // does not: GitHub's is used when GitHub has one. | |
| 466 | + | assert!(!answers_for_its_name(&web, &repo("setup-node", "actions", false))); | |
| 467 | + | } | |
| 440 | 468 | } | |
| 129 | 129 | Some(body) | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | + | /// The status GitHub answers a GET with, or None when it doesn't answer. | |
| 133 | + | async fn status_of(url: &str) -> Option<u16> { | |
| 134 | + | let headers = worker::Headers::new(); | |
| 135 | + | headers.set("user-agent", "git/2.47.0 (g1t)").ok()?; | |
| 136 | + | let mut init = worker::RequestInit::new(); | |
| 137 | + | init.with_method(worker::Method::Get).with_headers(headers); | |
| 138 | + | let request = worker::Request::new_with_init(url, &init).ok()?; | |
| 139 | + | let response = worker::Fetch::Request(request).send().await.ok()?; | |
| 140 | + | Some(response.status_code()) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// What GitHub's answer to a repository's advertisement says about it: | |
| 144 | + | /// Some(true) when it publishes the repository, Some(false) when it says | |
| 145 | + | /// there is none it will show (GitHub answers 401 for a repository that | |
| 146 | + | /// doesn't exist as for a private one), None for anything else, such as a | |
| 147 | + | /// rate limit or an error. | |
| 148 | + | pub(crate) fn published_by(status: Option<u16>) -> Option<bool> { | |
| 149 | + | match status? { | |
| 150 | + | 200 => Some(true), | |
| 151 | + | 401 | 404 => Some(false), | |
| 152 | + | _ => None, | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 132 | 156 | #[derive(Deserialize)] | |
| 133 | 157 | struct RefRow { | |
| 134 | 158 | sha: String, | |
| ⋯ | |||
| 152 | 176 | Ok(json!({ "source": "github", "sha": sha, "archive": archive, "note": note })) | |
| 153 | 177 | } | |
| 154 | 178 | ||
| 179 | + | /// Whether GitHub publishes `repository` (`owner/repo`): Some(true) when | |
| 180 | + | /// it does, or once named a commit of it for a job; Some(false) when it | |
| 181 | + | /// says it has no such repository; None when it could not say. | |
| 182 | + | pub(crate) async fn github_publishes(&self, repository: &str) -> Result<Option<bool>> { | |
| 183 | + | let repository = repository.to_ascii_lowercase(); | |
| 184 | + | let seen = self | |
| 185 | + | .db | |
| 186 | + | .prepare("SELECT sha FROM action_refs WHERE repository = ? LIMIT 1") | |
| 187 | + | .bind(&[repository.as_str().into()])? | |
| 188 | + | .first::<Value>(None) | |
| 189 | + | .await?; | |
| 190 | + | if seen.is_some() { | |
| 191 | + | return Ok(Some(true)); | |
| 192 | + | } | |
| 193 | + | Ok(published_by(status_of(&format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack")).await)) | |
| 194 | + | } | |
| 195 | + | ||
| 155 | 196 | /// The commit `git_ref` names in GitHub's `repository`, and a note when | |
| 156 | 197 | /// it is the one it last named because GitHub did not answer. | |
| 157 | 198 | async fn resolve_github(&self, repository: &str, git_ref: &str, now: u64) -> Result<(Option<String>, Option<String>)> { | |
| ⋯ | |||
| 338 | 379 | } | |
| 339 | 380 | ||
| 340 | 381 | #[test] | |
| 382 | + | fn only_an_answer_from_github_says_it_has_no_such_repository() { | |
| 383 | + | assert_eq!(published_by(Some(200)), Some(true)); | |
| 384 | + | assert_eq!(published_by(Some(401)), Some(false)); | |
| 385 | + | assert_eq!(published_by(Some(404)), Some(false)); | |
| 386 | + | // A rate limit, an error or no answer says nothing, so the name stays GitHub's. | |
| 387 | + | assert_eq!(published_by(Some(429)), None); | |
| 388 | + | assert_eq!(published_by(Some(503)), None); | |
| 389 | + | assert_eq!(published_by(None), None); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | #[test] | |
| 341 | 393 | fn an_advertisement_lists_its_refs_capabilities_aside() { | |
| 342 | 394 | let refs = parse_advertisement(&advertisement()); | |
| 343 | 395 | assert_eq!(refs.len(), 6); | |
| 26 | 26 | use g1t_contracts::github::*; | |
| 27 | 27 | use g1t_contracts::identity::{SignedIn, UserArgs}; | |
| 28 | 28 | use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after}; | |
| 29 | − | use g1t_contracts::{FailureCode, Outcome, User, claimable_username, is_reserved_name, is_valid_namespace, new_id}; | |
| 29 | + | use g1t_contracts::{FailureCode, Outcome, User, claimable_username, is_action_owner_name, is_claimable_name, is_reserved_name, is_valid_namespace, new_id}; | |
| 30 | 30 | use g1t_kit::now_ms; | |
| 31 | 31 | use g1t_secrets::Sealer; | |
| 32 | 32 | use serde::{Deserialize, Serialize}; | |
| ⋯ | |||
| 137 | 137 | } | |
| 138 | 138 | let out: String = out.trim_matches('-').chars().take(39).collect(); | |
| 139 | 139 | let out = out.trim_end_matches('-'); | |
| 140 | − | if is_reserved_name(out) { format!("{out}-gh") } else { out.to_owned() } | |
| 140 | + | if is_reserved_name(out) || is_action_owner_name(out) { format!("{out}-gh") } else { out.to_owned() } | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | 143 | /// What a return from GitHub should do. | |
| ⋯ | |||
| 458 | 458 | async fn username_free(&self, username: &str) -> Result<bool> { | |
| 459 | 459 | // Taken in any case: names are found lowercased. | |
| 460 | 460 | let username = &username.to_ascii_lowercase(); | |
| 461 | − | if !is_valid_namespace(username) { | |
| 461 | + | if !is_claimable_name(username) { | |
| 462 | 462 | return Ok(false); | |
| 463 | 463 | } | |
| 464 | 464 | let taken = self | |
| ⋯ | |||
| 1058 | 1058 | assert!(is_valid_namespace(&suggest_username("g1t"))); | |
| 1059 | 1059 | assert!(claimable_username(&suggest_username("G1T")).is_some()); | |
| 1060 | 1060 | assert_eq!(suggest_username("g1t-fan"), "g1t-fan"); | |
| 1061 | + | // A GitHub account named like a well-known action owner signs up | |
| 1062 | + | // under a name of its own too. | |
| 1063 | + | assert_eq!(suggest_username("Docker"), "Docker-gh"); | |
| 1064 | + | assert!(claimable_username(&suggest_username("actions")).is_some()); | |
| 1061 | 1065 | // So signing up goes ahead, rather than failing on the login. | |
| 1062 | 1066 | let facts = Facts { suggestion: suggest_username("g1t"), ..facts() }; | |
| 1063 | 1067 | assert_eq!(decide(&facts), Decision::Create("g1t-gh".to_owned())); | |
| 16 | 16 | use g1t_contracts::events::{NewEvent, Publish, WorkspaceRenamed}; | |
| 17 | 17 | use g1t_contracts::identity::*; | |
| 18 | 18 | use g1t_contracts::time::rfc3339; | |
| 19 | − | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, is_valid_namespace}; | |
| 19 | + | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, is_action_owner_name, is_valid_namespace}; | |
| 20 | 20 | use g1t_kit::now_ms; | |
| 21 | 21 | use serde::Deserialize; | |
| 22 | 22 | use worker::Result; | |
| ⋯ | |||
| 71 | 71 | "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.", | |
| 72 | 72 | ); | |
| 73 | 73 | } | |
| 74 | + | if is_action_owner_name(facts.wanted) { | |
| 75 | + | return refuse( | |
| 76 | + | FailureCode::Invalid, | |
| 77 | + | "That name is a GitHub account whose actions workflows use, so it is kept for that account. If it is yours, contact support to prove it.", | |
| 78 | + | ); | |
| 79 | + | } | |
| 74 | 80 | if facts.wanted == facts.current { | |
| 75 | 81 | return refuse(FailureCode::Invalid, "That is already this workspace's name."); | |
| 76 | 82 | } | |
| ⋯ | |||
| 359 | 365 | } | |
| 360 | 366 | ||
| 361 | 367 | #[test] | |
| 368 | + | fn no_workspace_is_renamed_to_a_github_action_owner() { | |
| 369 | + | for name in ["actions", "docker", "aws-actions"] { | |
| 370 | + | assert_eq!(refused(&facts("acme", name)), FailureCode::Invalid, "{name}"); | |
| 371 | + | } | |
| 372 | + | assert!(check(&facts("acme", "actions-team")).is_ok()); | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | #[test] | |
| 362 | 376 | fn refuses_names_in_use() { | |
| 363 | 377 | let taken = Facts { | |
| 364 | 378 | someone_elses_username: true, | |