flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/runner/src/bump.ts

124 lines5,085 bytesCodeBlame
1/**
2 * Security updates (`RunnerService.startBump`): what the security service
3 * asks for, checked, and the sandbox it becomes, which runs the runner's
4 * `bump` mode (crates/runner bump.rs). Pure, so it is tested on its own.
5 */
6import type { BumpArgs, RepoPath, User } from "@g1t/contracts";
7
8/** g1t's own identity: `g1t_contracts::system::{ID, USERNAME}`. */
9export const SYSTEM_ID = "g1t";
10export const SYSTEM_USERNAME = "g1t";
11
12/** The ecosystems the runner can update, by OSV's names. */
13export const BUMP_ECOSYSTEMS: readonly string[] = ["npm", "crates.io", "Go", "PyPI"];
14
15/** Long enough to clone, resolve and push; then the token stops working. */
16export const BUMP_TOKEN_TTL_SECONDS = 30 * 60;
17
18/** A security update's time cap, and what is reserved for it. */
19export const BUMP_MINUTES = 20;
20
21/** The most lockfiles one update names. */
22const MAX_LOCKFILES = 50;
23
24/**
25 * g1t itself, working in `workspace`: the actor of the work it does on its
26 * own, such as a security update or an agent it puts on one. Mirrors
27 * `g1t_contracts::User::system`. Identity makes its run credentials act
28 * for the workspace.
29 */
30export function systemActor(workspace: string): User {
31 return {
32 id: SYSTEM_ID,
33 username: SYSTEM_USERNAME,
34 kind: "system",
35 verified: true,
36 workspaces: [{ slug: workspace.toLowerCase(), role: "member" }],
37 };
38}
39
40/** Whether `user` is g1t itself. */
41export function isSystem(user: User | null | undefined): boolean {
42 return user?.kind === "system";
43}
44
45function isText(value: unknown): value is string {
46 return typeof value === "string" && value.trim().length > 0;
47}
48
49/** A name or version the runner passes to a tool as one argument. */
50function isArgument(text: string): boolean {
51 return text.length <= 214 && !text.startsWith("-") && /^[A-Za-z0-9@/._+~-]+$/.test(text);
52}
53
54/** A lockfile's path from the repository's root, inside it. */
55function isLockfilePath(path: unknown): boolean {
56 if (!isText(path) || path.length > 512 || path.startsWith("/") || path.includes("\\")) return false;
57 return path.split("/").every((part) => part !== "" && part !== "..");
58}
59
60/**
61 * What is wrong with a request for a security update, or null when it can
62 * start: a repository, an ecosystem the runner updates, a package and
63 * version it can pass to a tool, lockfiles inside the repository, and a
64 * branch under `prefix` (`UPDATE_BRANCH_PREFIX`).
65 */
66export function bumpProblem(input: unknown, prefix: string): string | null {
67 if (!input || typeof input !== "object") return "A security update needs its arguments.";
68 const args = input as Partial<BumpArgs>;
69 if (!args.repo || !isText(args.repo.namespace) || !isText(args.repo.name)) return "A security update needs its repository.";
70 if (!isText(args.ecosystem) || !BUMP_ECOSYSTEMS.includes(args.ecosystem)) {
71 return `g1t cannot update ${String(args.ecosystem)} dependencies; it updates ${BUMP_ECOSYSTEMS.join(", ")}.`;
72 }
73 if (!isText(args.package) || !isArgument(args.package.trim())) return "A security update needs the package's name.";
74 if (!isText(args.version) || !isArgument(args.version.trim())) return "A security update needs the version to raise it to.";
75 if (!Array.isArray(args.lockfiles) || args.lockfiles.length === 0 || args.lockfiles.length > MAX_LOCKFILES) {
76 return `A security update names between 1 and ${MAX_LOCKFILES} lockfiles.`;
77 }
78 const outside = args.lockfiles.find((path) => !isLockfilePath(path));
79 if (outside !== undefined) return `${String(outside)} is not a path inside the repository.`;
80 if (
81 !isText(args.branch) ||
82 !args.branch.startsWith(prefix) ||
83 args.branch.length <= prefix.length ||
84 args.branch.length > 200 ||
85 args.branch.includes("..") ||
86 /[\s~^:?*[\\]/.test(args.branch)
87 ) {
88 return `A security update's branch starts with ${prefix}.`;
89 }
90 return null;
91}
92
93/** The sandbox for one update: the same branch is the same sandbox. */
94export function bumpSandboxName(args: BumpArgs): string {
95 return `bump:${args.repo.namespace}/${args.repo.name}:${args.branch}`.toLowerCase();
96}
97
98/** The repository's clone URL, as every sandbox is given it. */
99export function remoteOf(repo: RepoPath): string {
100 return `https://g1t.sh/${repo.namespace}/${repo.name}.git`;
101}
102
103/**
104 * The sandbox's variables: what `bump` mode reads. `token` is a run
105 * credential that reads the repository and pushes only `args.branch`.
106 */
107export function bumpEnv(args: BumpArgs, baseBranch: string, token: string): Record<string, string> {
108 const pkg = args.package.trim();
109 const version = args.version.trim();
110 return {
111 MODE: "bump",
112 // The credential acts for the workspace; git sends any name with it.
113 G1T_USER: args.repo.namespace.toLowerCase(),
114 G1T_TOKEN: token,
115 GIT_REMOTE: remoteOf(args.repo),
116 GIT_BRANCH_BASE: baseBranch,
117 GIT_BRANCH: args.branch,
118 BUMP_ECOSYSTEM: args.ecosystem,
119 BUMP_PACKAGE: pkg,
120 BUMP_VERSION: version,
121 BUMP_LOCKFILES: JSON.stringify(args.lockfiles),
122 COMMIT_MESSAGE: isText(args.message) ? args.message : `Update ${pkg} to ${version}`,
123 };
124}