Skip to content

Commit

Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'

syntaqxcommitted Parents9058938d361fc7Browse files
11 files+219−130/11 viewed
+4−2
126126
127127 A step's `uses: owner/repo@ref` (or `owner/repo/path@ref`) and a job's
128128 `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:
130131
131132 | The repository | What happens |
132133 | --- | --- |
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. |
134135 | 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. |
135136 | 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. |
136137 | 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. |
137139
138140 `ref` is a branch, a tag or a commit. A reusable workflow may be under
139141 `.g1t/workflows/` or `.github/workflows/`; a `.github/workflows/` path
+6−1
1717 be registered by anyone else, signing in works in any case,
1818 `g1t.sh/u/ANA-LOPEZ` opens the same profile, and an `@ana-lopez` mention
1919 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.
2126
2227 Before you can do anything else, you [confirm your email
2328 address](#confirming-your-email-address) with the code g1t emails you,
+2−1
154154 register a username with the old name. Links and remotes that still use
155155 it then reach whatever has the name, or nothing.
156156 - 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
158159 not another workspace's slug or someone else's username.
159160
160161 ## Data residency
+1−1
5252
5353 pub use ids::{id_floor, new_id};
5454 pub use names::{
55− Username, aliasable_name, claimable_namespace, claimable_username, is_namespace_shaped, is_reserved_name, is_route_name,
55+ Username, aliasable_name, claimable_namespace, is_action_owner_name, is_claimable_name, claimable_username, is_namespace_shaped, is_reserved_name, is_route_name,
5656 is_valid_namespace, is_valid_repo_name,
5757 };
5858 pub use outcome::{Failure, FailureCode, Outcome};
+58−2
5757 /// `aliases.rs`).
5858 const OWN: &[&str] = &["g1t", "g1t-agent", "ghost"];
5959
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+
6084 /// Whether `value`, whatever its case, is a name nobody can register or
6185 /// rename a workspace to: a route, or g1t's own.
6286 pub fn is_reserved_name(value: &str) -> bool {
6387 is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim()))
6488 }
6589
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+
66103 /// Whether `value`, whatever its case, is one of the site's own routes.
67104 pub fn is_route_name(value: &str) -> bool {
68105 let value = value.trim();
99136 /// reserved.
100137 pub fn claimable_namespace(value: &str) -> Option<String> {
101138 let value = value.trim().to_lowercase();
102− is_valid_namespace(&value).then_some(value)
139+ is_claimable_name(&value).then_some(value)
103140 }
104141
105142 /// A username as someone typed it, if it can be registered: the name
114151 return None;
115152 }
116153 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() })
118155 }
119156
120157 /// A username in its two forms: see [`claimable_username`].
169206 }
170207
171208 #[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]
172228 fn names_near_g1ts_are_anyones() {
173229 assert!(!is_reserved_name("g1t-agents"));
174230 assert_eq!(claimable_namespace("G1T-Bot").as_deref(), Some("g1t-bot"));
+11−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
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";
55
66 test("usernames take letters of either case, digits and single hyphens, up to 39", () => {
77 for (const good of ["ana", "Ana", "ANA-Lopez", "a", "A1", "x".repeat(39)]) assert.ok(isValidUsername(good), good);
3131 for (const name of ["Ana", "ana-lopez", "A1"]) assert.ok(pattern.test(name), name);
3232 for (const name of ["-ana", "ana-", "an--a", "an_a"]) assert.ok(!pattern.test(name), name);
3333 });
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+});
+35−1
2525 "download", "downloads",
2626 ]);
2727
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+
2862 /** Whether `value`, whatever its case, is a name nobody can register: a route, or g1t's own. */
2963 export function isReservedName(value: string): boolean {
3064 return RESERVED.has(value.trim().toLowerCase());
5589
5690 export function isValidUsername(value: string): boolean {
5791 const name = value.trim();
58− return USERNAME.test(name) && !isReservedName(name);
92+ return USERNAME.test(name) && !isReservedName(name) && !isActionOwnerName(name);
5993 }
6094
6195 /** The name a person is found, linked and mentioned by: lowercased. */
+28−0
7575 Ok(())
7676 }
7777
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+
7887 /// Where another repository's action or workflow comes from.
7988 pub(crate) enum Found {
8089 /// g1t has it, and the caller may use it.
91100 let Some(target) = self.visible_repo(&path, &Some(ws.clone())).await? else {
92101 return Ok(Outcome::Ok(Found::GitHub));
93102 };
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+ }
94110 let level = if target.is_private && target.id != caller.id { self.access_level_of(&target.id).await? } else { "none" };
95111 Ok(match may_use(caller, &target, level) {
96112 Ok(()) => Outcome::Ok(Found::G1t(target)),
437453 assert!(may_use(&repo("x", "other", true), &shared, "organization").unwrap_err().contains("only repositories in acme"));
438454 assert!(may_use(&repo("site", "acme", false), &shared, "organization").unwrap_err().contains("public repository"));
439455 }
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+ }
440468 }
+52−0
129129 Some(body)
130130 }
131131
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+
132156 #[derive(Deserialize)]
133157 struct RefRow {
134158 sha: String,
152176 Ok(json!({ "source": "github", "sha": sha, "archive": archive, "note": note }))
153177 }
154178
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+
155196 /// The commit `git_ref` names in GitHub's `repository`, and a note when
156197 /// it is the one it last named because GitHub did not answer.
157198 async fn resolve_github(&self, repository: &str, git_ref: &str, now: u64) -> Result<(Option<String>, Option<String>)> {
338379 }
339380
340381 #[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]
341393 fn an_advertisement_lists_its_refs_capabilities_aside() {
342394 let refs = parse_advertisement(&advertisement());
343395 assert_eq!(refs.len(), 6);
+7−3
2626 use g1t_contracts::github::*;
2727 use g1t_contracts::identity::{SignedIn, UserArgs};
2828 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};
3030 use g1t_kit::now_ms;
3131 use g1t_secrets::Sealer;
3232 use serde::{Deserialize, Serialize};
137137 }
138138 let out: String = out.trim_matches('-').chars().take(39).collect();
139139 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() }
141141 }
142142
143143 /// What a return from GitHub should do.
458458 async fn username_free(&self, username: &str) -> Result<bool> {
459459 // Taken in any case: names are found lowercased.
460460 let username = &username.to_ascii_lowercase();
461− if !is_valid_namespace(username) {
461+ if !is_claimable_name(username) {
462462 return Ok(false);
463463 }
464464 let taken = self
10581058 assert!(is_valid_namespace(&suggest_username("g1t")));
10591059 assert!(claimable_username(&suggest_username("G1T")).is_some());
10601060 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());
10611065 // So signing up goes ahead, rather than failing on the login.
10621066 let facts = Facts { suggestion: suggest_username("g1t"), ..facts() };
10631067 assert_eq!(decide(&facts), Decision::Create("g1t-gh".to_owned()));
+15−1
1616 use g1t_contracts::events::{NewEvent, Publish, WorkspaceRenamed};
1717 use g1t_contracts::identity::*;
1818 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};
2020 use g1t_kit::now_ms;
2121 use serde::Deserialize;
2222 use worker::Result;
7171 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
7272 );
7373 }
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+ }
7480 if facts.wanted == facts.current {
7581 return refuse(FailureCode::Invalid, "That is already this workspace's name.");
7682 }
359365 }
360366
361367 #[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]
362376 fn refuses_names_in_use() {
363377 let taken = Facts {
364378 someone_elses_username: true,