Skip to content

Commit

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
Claudeagent and Claudeagentcommitted Parent3dd024dBrowse 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
5151
5252 pub use ids::{id_floor, new_id};
5353 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,
5555 is_valid_namespace, is_valid_repo_name,
5656 };
5757 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,