Skip to content

g1t/services/runner/src/bump.ts

238 lines11,167 bytesCodeBlame

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.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1/**
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2 * Security and version updates (`RunnerService.startBump`): what the
3 * security service asks for, checked, and the sandbox it becomes, which
4 * runs the runner's `bump` mode (crates/runner bump.rs). Pure, so it is
5 * tested on its own.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily6 */
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar7import type { BumpArgs, BumpPackage, BumpRegistry, RepoPath, User } from "@g1t/contracts";
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily8
9/** g1t's own identity: `g1t_contracts::system::{ID, USERNAME}`. */
10export const SYSTEM_ID = "g1t";
11export const SYSTEM_USERNAME = "g1t";
12
13/** The ecosystems the runner can update, by OSV's names. */
14export const BUMP_ECOSYSTEMS: readonly string[] = ["npm", "crates.io", "Go", "PyPI"];
15
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar16/** How a version update changes a manifest's requirement (`versioning-strategy`). */
17export const BUMP_STRATEGIES: readonly string[] = ["increase", "increase-if-necessary", "widen", "lockfile-only"];
18
19/** The kinds of private registry the runner can point its tools at. */
20export const BUMP_REGISTRY_TYPES: readonly string[] = ["npm-registry", "cargo-registry", "python-index", "goproxy-server"];
21
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily22/** Long enough to clone, resolve and push; then the token stops working. */
23export const BUMP_TOKEN_TTL_SECONDS = 30 * 60;
24
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar25/** An update's time cap, and what is reserved for it. */
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily26export const BUMP_MINUTES = 20;
27
28/** The most lockfiles one update names. */
29const MAX_LOCKFILES = 50;
30
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar31/** The most packages one grouped update raises. */
32const MAX_PACKAGES = 50;
33
34/** The most private registries one update reads. */
35const MAX_REGISTRIES = 20;
36
37/** The longest text in a registry's entry. */
38const MAX_REGISTRY_TEXT = 2000;
39
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily40/**
41 * g1t itself, working in `workspace`: the actor of the work it does on its
42 * own, such as a security update or an agent it puts on one. Mirrors
43 * `g1t_contracts::User::system`. Identity makes its run credentials act
44 * for the workspace.
45 */
46export function systemActor(workspace: string): User {
47 return {
48 id: SYSTEM_ID,
49 username: SYSTEM_USERNAME,
50 kind: "system",
51 verified: true,
52 workspaces: [{ slug: workspace.toLowerCase(), role: "member" }],
53 };
54}
55
56/** Whether `user` is g1t itself. */
57export function isSystem(user: User | null | undefined): boolean {
58 return user?.kind === "system";
59}
60
61function isText(value: unknown): value is string {
62 return typeof value === "string" && value.trim().length > 0;
63}
64
65/** A name or version the runner passes to a tool as one argument. */
66function isArgument(text: string): boolean {
67 return text.length <= 214 && !text.startsWith("-") && /^[A-Za-z0-9@/._+~-]+$/.test(text);
68}
69
70/** A lockfile's path from the repository's root, inside it. */
71function isLockfilePath(path: unknown): boolean {
72 if (!isText(path) || path.length > 512 || path.startsWith("/") || path.includes("\\")) return false;
73 return path.split("/").every((part) => part !== "" && part !== "..");
74}
75
76/**
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar77 * Whether git takes `branch` as a branch's name, short of a full ref: what
78 * a version update's branch, named by the dependency update file, must be.
79 * Mirrors `branch_name_ok` in crates/runner bump.rs.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily80 */
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar81export function isBranchName(branch: unknown): branch is string {
82 if (!isText(branch) || branch.length > 200) return false;
83 // eslint-disable-next-line no-control-regex
84 if (/[\s\x00-\x1f\x7f~^:?*[\\]/.test(branch) || branch.includes("..") || branch.includes("@{")) return false;
85 if (branch.startsWith("-") || branch.startsWith("/") || branch.startsWith("refs/")) return false;
86 return !branch.endsWith("/") && !branch.endsWith(".lock");
87}
88
89/** Absent, or text no longer than a registry's entry holds. */
90function isRegistryText(value: unknown): boolean {
91 return value === undefined || (typeof value === "string" && value.length <= MAX_REGISTRY_TEXT);
92}
93
94/** What is wrong with one private registry, or null. */
95function registryProblem(registry: unknown): string | null {
96 if (!registry || typeof registry !== "object") return "A private registry needs its type and URL.";
97 const entry = registry as Partial<BumpRegistry>;
98 if (!isText(entry.type) || !BUMP_REGISTRY_TYPES.includes(entry.type)) {
99 return `g1t cannot read a ${String(entry.type)} registry; it reads ${BUMP_REGISTRY_TYPES.join(", ")}.`;
100 }
101 if (!isText(entry.url) || entry.url.length > MAX_REGISTRY_TEXT || !/^https:\/\/[^\s/]+\S*$/.test(entry.url)) {
102 return "A private registry's URL starts with https://.";
103 }
104 if (!isRegistryText(entry.username) || !isRegistryText(entry.password) || !isRegistryText(entry.token)) {
105 return `A private registry's credentials are text of at most ${MAX_REGISTRY_TEXT} characters.`;
106 }
107 if (entry.replacesBase !== undefined && typeof entry.replacesBase !== "boolean") {
108 return "A private registry's replacesBase is true or false.";
109 }
110 if (entry.scopes !== undefined) {
111 if (!Array.isArray(entry.scopes) || entry.scopes.length > MAX_REGISTRIES) {
112 return `A private registry serves at most ${MAX_REGISTRIES} scopes.`;
113 }
114 const scope = entry.scopes.find((scope) => typeof scope !== "string" || !/^@[A-Za-z0-9][A-Za-z0-9._-]*$/.test(scope));
115 if (scope !== undefined) return `${String(scope)} is not an npm scope.`;
116 }
117 return null;
118}
119
120/**
121 * What is wrong with a request for an update, or null when it can start: a
122 * repository, an ecosystem the runner updates, packages and versions it
123 * can pass to a tool, lockfiles inside the repository, and a branch. A
124 * security update's branch starts with `prefix` (`UPDATE_BRANCH_PREFIX`); a
125 * version update (`kind: "version"`) names any branch git takes, and may
126 * also choose a versioning strategy and name private registries.
127 */
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily128export function bumpProblem(input: unknown, prefix: string): string | null {
129 if (!input || typeof input !== "object") return "A security update needs its arguments.";
130 const args = input as Partial<BumpArgs>;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar131 if (args.kind !== undefined && args.kind !== "version") return `g1t cannot make a ${String(args.kind)} update.`;
132 const versionUpdate = args.kind === "version";
133 const what = versionUpdate ? "A version update" : "A security update";
134 if (!args.repo || !isText(args.repo.namespace) || !isText(args.repo.name)) return `${what} needs its repository.`;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily135 if (!isText(args.ecosystem) || !BUMP_ECOSYSTEMS.includes(args.ecosystem)) {
136 return `g1t cannot update ${String(args.ecosystem)} dependencies; it updates ${BUMP_ECOSYSTEMS.join(", ")}.`;
137 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar138 if (!isText(args.package) || !isArgument(args.package.trim())) return `${what} needs the package's name.`;
139 if (!isText(args.version) || !isArgument(args.version.trim())) return `${what} needs the version to raise it to.`;
140 if (args.packages !== undefined) {
141 if (!Array.isArray(args.packages) || args.packages.length > MAX_PACKAGES) return `${what} raises at most ${MAX_PACKAGES} packages.`;
142 for (const entry of args.packages as unknown[]) {
143 const { package: name, version } = (entry && typeof entry === "object" ? entry : {}) as Partial<BumpPackage>;
144 if (!isText(name) || !isArgument(name.trim())) return `${what} needs each package's name.`;
145 if (!isText(version) || !isArgument(version.trim())) return `${what} needs the version to raise ${name.trim()} to.`;
146 }
147 }
148 if (args.strategy !== undefined && (typeof args.strategy !== "string" || !BUMP_STRATEGIES.includes(args.strategy))) {
149 return `${String(args.strategy)} is not a versioning strategy; g1t knows ${BUMP_STRATEGIES.join(", ")}.`;
150 }
151 if (args.force !== undefined && typeof args.force !== "boolean") return `${what}'s force is true or false.`;
152 if (args.base !== undefined && !isBranchName(args.base)) return `${String(args.base)} is not a branch name git takes.`;
153 if (args.registries !== undefined) {
154 if (!Array.isArray(args.registries) || args.registries.length > MAX_REGISTRIES) {
155 return `${what} reads at most ${MAX_REGISTRIES} private registries.`;
156 }
157 for (const registry of args.registries as unknown[]) {
158 const problem = registryProblem(registry);
159 if (problem) return problem;
160 }
161 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily162 if (!Array.isArray(args.lockfiles) || args.lockfiles.length === 0 || args.lockfiles.length > MAX_LOCKFILES) {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar163 return `${what} names between 1 and ${MAX_LOCKFILES} lockfiles.`;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily164 }
165 const outside = args.lockfiles.find((path) => !isLockfilePath(path));
166 if (outside !== undefined) return `${String(outside)} is not a path inside the repository.`;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar167 if (versionUpdate) {
168 return isBranchName(args.branch) ? null : `${String(args.branch)} is not a branch name git takes.`;
169 }
170 if (!isBranchName(args.branch) || !args.branch.startsWith(prefix) || args.branch.length <= prefix.length) {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily171 return `A security update's branch starts with ${prefix}.`;
172 }
173 return null;
174}
175
176/** The sandbox for one update: the same branch is the same sandbox. */
177export function bumpSandboxName(args: BumpArgs): string {
178 return `bump:${args.repo.namespace}/${args.repo.name}:${args.branch}`.toLowerCase();
179}
180
181/** The repository's clone URL, as every sandbox is given it. */
182export function remoteOf(repo: RepoPath): string {
183 return `https://g1t.sh/${repo.namespace}/${repo.name}.git`;
184}
185
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar186/** The packages an update raises: `packages`, or else its one package. */
187export function bumpPackages(args: BumpArgs): BumpPackage[] {
188 const listed = (args.packages ?? []).map((entry) => ({ package: entry.package.trim(), version: entry.version.trim() }));
189 return listed.length > 0 ? listed : [{ package: args.package.trim(), version: args.version.trim() }];
190}
191
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily192/**
193 * The sandbox's variables: what `bump` mode reads. `token` is a run
194 * credential that reads the repository and pushes only `args.branch`.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar195 * `BUMP_PACKAGE` and `BUMP_VERSION` are the first of `BUMP_PACKAGES`.
196 * `BUMP_REGISTRIES` holds credentials, which the runner writes only
197 * outside the clone.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily198 */
199export function bumpEnv(args: BumpArgs, baseBranch: string, token: string): Record<string, string> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar200 const packages = bumpPackages(args);
201 const { package: pkg, version } = packages[0]!;
202 const named = packages.length === 1 ? `${pkg} to ${version}` : `${pkg} and ${packages.length - 1} more`;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily203 return {
204 MODE: "bump",
205 // The credential acts for the workspace; git sends any name with it.
206 G1T_USER: args.repo.namespace.toLowerCase(),
207 G1T_TOKEN: token,
208 GIT_REMOTE: remoteOf(args.repo),
209 GIT_BRANCH_BASE: baseBranch,
210 GIT_BRANCH: args.branch,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar211 BUMP_KIND: args.kind === "version" ? "version" : "security",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily212 BUMP_ECOSYSTEM: args.ecosystem,
213 BUMP_PACKAGE: pkg,
214 BUMP_VERSION: version,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar215 BUMP_PACKAGES: JSON.stringify(packages),
216 BUMP_STRATEGY: args.strategy ?? "increase",
217 BUMP_FORCE: args.force ? "1" : "0",
218 BUMP_REGISTRIES: JSON.stringify(args.registries ?? []),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily219 BUMP_LOCKFILES: JSON.stringify(args.lockfiles),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar220 COMMIT_MESSAGE: isText(args.message) ? args.message : `Update ${named}`,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily221 };
222}
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar223
224/**
225 * The hosts of an update's private registries, which its sandbox may reach
226 * on top of the public ones (`BUMP_HOSTS`).
227 */
228export function registryHosts(args: BumpArgs): string[] {
229 const hosts = new Set<string>();
230 for (const registry of args.registries ?? []) {
231 try {
232 hosts.add(new URL(registry.url).host);
233 } catch {
234 // bumpProblem refuses a registry without a URL.
235 }
236 }
237 return [...hosts];
238}