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.
| The statement is a month at a time, a line per kind of charge | 1 | /** |
| 2 | * The deployments service: a project's production, deployed from its | |
| 3 | * default branch on every push, and a live preview of every branch with an | |
| 4 | * open pull request, on g1t.page. | |
| 5 | * | |
| 6 | * It reacts to events (a pull request opened, ready, pushed to, closed or | |
| 7 | * merged; a push to the default branch) for every project built from the | |
| 8 | * repository, asks billing whether the workspace pays for Deployments, and | |
| 9 | * asks the runner to build the commit in a sandbox. The sandbox reports | |
| 10 | * back through the API with a token for that build alone; this service | |
| 11 | * opens the upload of its files and puts the finished app in the Workers | |
| 12 | * for Platforms namespace, where the `*.g1t.page` dispatcher finds it by | |
| 13 | * hostname. | |
| 14 | * | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 15 | * A g1t Actions job can open a build too (`startFromWorkflow`, for |
| 16 | * `POST /repos/{owner}/{repo}/deployments/builds` and the `g1t/deploy` | |
| 17 | * action) and build it in its own checkout with `g1t-runner deploy`; it | |
| 18 | * reports the same way, and its time is the job's, not a sandbox's. | |
| 19 | * | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 20 | * Nothing here is free, and nothing is capped. On the plan, every build |
| 21 | * second is charged as it happens; requests, CPU time and custom domains | |
| 22 | * are charged from the first once the month is over, all at cost plus the | |
| 23 | * margin and from the plan's included usage first. No count of projects, | |
| 24 | * previews or apps ever stops or pauses a workspace: apps are not metered | |
| 25 | * at all. Only the workspace's spend limit pauses its apps | |
| 26 | * (`holdToLimits`), and only the plan ending takes them down. A Worker | |
| 27 | * runs only while it answers a request, so an app no one visits costs | |
| 28 | * nothing, and a preview is taken down when its pull request closes or | |
| 29 | * after its project's idle days. | |
| The statement is a month at a time, a line per kind of charge | 30 | * |
| 31 | * Reached through service bindings (`POST /rpc/<method>`) and, for a | |
| 32 | * build's reports, through the API (`POST /jobs/<id>/<step>`). | |
| 33 | */ | |
| 34 | ||
| 35 | import { | |
| 36 | CUSTOM_DOMAIN_TARGET, | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 37 | DEPLOYMENT_COSTS, |
| The statement is a month at a time, a line per kind of charge | 38 | SLUG_HOLD_DAYS, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 39 | ComputeGate, |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 40 | actionsClient, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 41 | allows, |
| The statement is a month at a time, a line per kind of charge | 42 | billingClient, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 43 | can, |
| 44 | needs, | |
| 45 | permission, | |
| 46 | sandboxEstimateMicros, | |
| 47 | currentMovedPath, | |
| The statement is a month at a time, a line per kind of charge | 48 | currentWorkspaceSlug, |
| Events: review requests, assignments, stops and deployments are published | 49 | eventsClient, |
| The statement is a month at a time, a line per kind of charge | 50 | fail, |
| 51 | identityClient, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 52 | isProtectedWorkspace, |
| Merge branch 'mirroring' into artifacts-mode | 53 | mirrorWritable, |
| The statement is a month at a time, a line per kind of charge | 54 | newId, |
| 55 | ok, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 56 | openD1, |
| The statement is a month at a time, a line per kind of charge | 57 | projectsClient, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 58 | repoMove, |
| The statement is a month at a time, a line per kind of charge | 59 | reposClient, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 60 | staleMovedPaths, |
| The statement is a month at a time, a line per kind of charge | 61 | staleSlugs, |
| 62 | workClient, | |
| 63 | type DeployKind, | |
| 64 | type DeploySettings, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 65 | type DetectedKind, |
| The statement is a month at a time, a line per kind of charge | 66 | type DeployStatus, |
| 67 | type DeployUsage, | |
| 68 | type Deployment, | |
| 69 | type Domain, | |
| 70 | type G1tEvent, | |
| 71 | type LiveApp, | |
| 72 | type Project, | |
| 73 | type ProjectDeploys, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 74 | type RepoMove, |
| The statement is a month at a time, a line per kind of charge | 75 | type ProjectDomains, |
| 76 | type ProjectRef, | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 77 | workOwner, |
| The statement is a month at a time, a line per kind of charge | 78 | type RepoPath, |
| 79 | type Result, | |
| 80 | type ServiceBinding, | |
| 81 | type User, | |
| 82 | type Viewer, | |
| 83 | } from "@g1t/contracts"; | |
| 84 | ||
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 85 | import { NEEDS, repoRef, trustedOutright, type Method } from "./access"; |
| The statement is a month at a time, a line per kind of charge | 86 | import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare"; |
| 87 | import { CustomHostnames } from "./custom-hostnames"; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 88 | import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains"; |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 89 | import { monthCost, movesMeter } from "./metering"; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 90 | import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves"; |
| 91 | import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries"; | |
| The statement is a month at a time, a line per kind of charge | 92 | import { appHost, appUrl, label, uniqueLabel } from "./names"; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 93 | import { RepoDeployments, sourceOf } from "./repo-deployments"; |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 94 | import { NEEDS_WRITE, commitOf, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds"; |
| The statement is a month at a time, a line per kind of charge | 95 | |
| 96 | type Env = { | |
| 97 | DB: D1Database; | |
| 98 | REPOS: ServiceBinding; | |
| 99 | WORK: ServiceBinding; | |
| 100 | IDENTITY: ServiceBinding; | |
| 101 | BILLING: ServiceBinding; | |
| 102 | RUNNER: ServiceBinding; | |
| 103 | PROJECTS: ServiceBinding; | |
| 104 | /** Secrets and variables: the actions service holds the one store. */ | |
| 105 | ACTIONS: ServiceBinding; | |
| Events: review requests, assignments, stops and deployments are published | 106 | /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */ |
| 107 | EVENTS?: ServiceBinding; | |
| The statement is a month at a time, a line per kind of charge | 108 | /** Secret: scoped to Workers scripts and analytics on g1t's account. */ |
| 109 | CLOUDFLARE_API_TOKEN?: string; | |
| 110 | CLOUDFLARE_ACCOUNT_ID: string; | |
| 111 | DISPATCH_NAMESPACE: string; | |
| 112 | SITE: string; | |
| 113 | /** Custom domains: hostname to app, read by the dispatcher. */ | |
| 114 | DOMAINS?: KVNamespace; | |
| 115 | /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */ | |
| 116 | CUSTOM_HOSTNAMES_ZONE_ID?: string; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 117 | /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */ |
| 118 | SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> }; | |
| The statement is a month at a time, a line per kind of charge | 119 | }; |
| 120 | ||
| 121 | /** A build that has not reported in this long has died. */ | |
| 122 | const BUILD_TIMEOUT_MS = 45 * 60 * 1000; | |
| 123 | /** A script in the namespace that no app holds, older than this, is removed. */ | |
| 124 | const ORPHAN_AFTER_MS = 60 * 60 * 1000; | |
| 125 | const LIST_LIMIT = 50; | |
| 126 | const STATUS_CONTEXT = "g1t / deploy"; | |
| 127 | /** | |
| 128 | * Deliveries of `workspace.renamed` that wait for the projects service to | |
| 129 | * have seen it too, before going ahead with the new slug regardless. | |
| 130 | */ | |
| 131 | const RENAME_WAITS = 3; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 132 | /** Why a build under way was dropped by a move; the move builds it again under the new name. */ |
| 133 | const MOVED_ERROR = "The repository moved: built again under its new name."; | |
| 134 | const RENAMED_ERROR = "The workspace was renamed: built again under its new name."; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 135 | /** |
| 136 | * A move's rebuild that could not start, or failed, is tried again by the | |
| 137 | * sweep after this long, doubled for each failure after the first, up to | |
| 138 | * `MAX_IDENTICAL_FAILURES` (see retries.ts). | |
| 139 | */ | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 140 | const MOVE_RETRY_MS = 60 * 60 * 1000; |
| The statement is a month at a time, a line per kind of charge | 141 | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 142 | /** A build's report of what it found the project to be, if it is one g1t knows. */ |
| 143 | export function detectedKind(value: unknown): DetectedKind | null { | |
| 144 | return value === "workers" || value === "static" || value === "html" ? value : null; | |
| 145 | } | |
| 146 | ||
| The statement is a month at a time, a line per kind of charge | 147 | const now = () => new Date().toISOString(); |
| 148 | const month = (at = new Date()) => at.toISOString().slice(0, 7); | |
| 149 | ||
| 150 | async function sha256(text: string): Promise<string> { | |
| 151 | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); | |
| 152 | return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 153 | } | |
| 154 | ||
| 155 | function randomToken(): string { | |
| 156 | return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 157 | } | |
| 158 | ||
| 159 | function isMember(viewer: Viewer, slug: string): boolean { | |
| 160 | return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase()); | |
| 161 | } | |
| 162 | ||
| 163 | /** The repository a project builds from. */ | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 164 | /** One compute gate per isolate, so entitlements and prices are kept between calls. */ |
| 165 | let computeGate: ComputeGate | null = null; | |
| 166 | function gateFor(billing: ServiceBinding): ComputeGate { | |
| 167 | computeGate ??= new ComputeGate(billing); | |
| 168 | return computeGate; | |
| 169 | } | |
| 170 | ||
| 171 | /** The longest a build may run, in minutes: as long as its read token lasts. */ | |
| 172 | const BUILD_MINUTES = 30; | |
| 173 | ||
| 174 | /** | |
| 175 | * A build that was skipped before it started (its plan, its limit, or | |
| 176 | * billing's refusal) as a failure, so whoever asked for it sees why. | |
| 177 | */ | |
| 178 | function notStarted(result: Result<Deployment>): Result<Deployment> { | |
| 179 | if (result.ok && result.value.status === "skipped" && result.value.error) { | |
| 180 | return fail("payment_required", result.value.error); | |
| 181 | } | |
| 182 | return result; | |
| 183 | } | |
| 184 | ||
| The statement is a month at a time, a line per kind of charge | 185 | function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } { |
| 186 | if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far."); | |
| 187 | return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch }; | |
| 188 | } | |
| 189 | ||
| 190 | type SettingsRow = { | |
| 191 | project_id: string; | |
| 192 | workspace: string; | |
| 193 | slug: string; | |
| 194 | repo_id: string; | |
| 195 | enabled: number; | |
| 196 | previews: number; | |
| 197 | production: number; | |
| 198 | build_command: string | null; | |
| 199 | output_dir: string | null; | |
| 200 | idle_days: number; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 201 | /** Whether it builds through its managed deploy workflow; see `syncManagedDeploy`. */ |
| 202 | via_workflow?: number; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 203 | /** Set while its repository is deleted (restorable); see `repoDeleted`. */ |
| 204 | repo_deleted_at: string | null; | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 205 | /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */ |
| 206 | workspace_deleted_at?: string | null; | |
| The statement is a month at a time, a line per kind of charge | 207 | }; |
| 208 | ||
| 209 | type DeploymentRow = { | |
| 210 | id: string; | |
| 211 | project_id: string; | |
| 212 | workspace: string; | |
| 213 | slug: string; | |
| 214 | repo_id: string; | |
| 215 | repo: string; | |
| 216 | kind: DeployKind; | |
| 217 | branch: string | null; | |
| 218 | number: number | null; | |
| 219 | commit_sha: string; | |
| 220 | script: string; | |
| 221 | status: DeployStatus; | |
| 222 | error: string | null; | |
| 223 | warnings: string; | |
| 224 | log: string | null; | |
| 225 | token_hash: string | null; | |
| 226 | trusted: number; | |
| 227 | build_seconds: number | null; | |
| 228 | created_by: string; | |
| 229 | created_at: string; | |
| 230 | finished_at: string | null; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 231 | /** Set for a build a g1t Actions job runs (migration 0011): the job pays for its time. */ |
| 232 | run_id?: string | null; | |
| 233 | job_id?: string | null; | |
| 234 | }; | |
| 235 | ||
| 236 | /** What opening any build takes; see `open`. */ | |
| 237 | type OpenInput = { | |
| 238 | project: Project; | |
| 239 | kind: DeployKind; | |
| 240 | branch: string | null; | |
| 241 | number: number | null; | |
| 242 | commit: string; | |
| 243 | createdBy: string; | |
| 244 | settings: SettingsRow; | |
| 245 | /** A push, or work by a member or an agent; see `insider`. */ | |
| 246 | trusted: boolean; | |
| 247 | /** The g1t Actions job that builds it, for a build opened from a workflow. */ | |
| 248 | workflow?: { runId: string; jobId: string }; | |
| The statement is a month at a time, a line per kind of charge | 249 | }; |
| 250 | ||
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 251 | /** Why a build was not opened: the plan or the limit, or g1t itself. */ |
| 252 | type Refusal = "payment_required" | "conflict"; | |
| 253 | ||
| 254 | /** A sandbox's reservation with billing; none for a workflow job's build. */ | |
| 255 | type Compute = { reservation: string | null; microsPerSecond: number; maxRunMinutes: number | null }; | |
| 256 | ||
| 257 | type Opened = | |
| 258 | | { refused: string; code: Refusal; id: string } | |
| 259 | | { | |
| 260 | refused: null; | |
| 261 | id: string; | |
| 262 | token: string; | |
| 263 | script: string; | |
| 264 | build: { secrets: Record<string, string>; variables: Record<string, string> }; | |
| 265 | compute: Compute; | |
| 266 | }; | |
| 267 | ||
| The statement is a month at a time, a line per kind of charge | 268 | type AppRow = { |
| 269 | script: string; | |
| 270 | project_id: string; | |
| 271 | workspace: string; | |
| 272 | slug: string; | |
| 273 | kind: DeployKind; | |
| 274 | branch: string | null; | |
| 275 | number: number | null; | |
| 276 | commit_sha: string; | |
| 277 | deployed_at: string; | |
| 278 | created_at: string; | |
| 279 | last_request_at: string | null; | |
| 280 | /** Set while its workspace is over its limit; see `holdToLimits`. */ | |
| 281 | paused_at: string | null; | |
| 282 | }; | |
| 283 | ||
| 284 | function toDeployment(row: DeploymentRow): Deployment { | |
| 285 | return { | |
| 286 | id: row.id, | |
| 287 | kind: row.kind, | |
| 288 | branch: row.branch, | |
| 289 | number: row.number, | |
| 290 | commit: row.commit_sha, | |
| 291 | status: row.status, | |
| 292 | url: appUrl(row.script), | |
| 293 | error: row.error, | |
| 294 | warnings: JSON.parse(row.warnings || "[]") as string[], | |
| 295 | buildSeconds: row.build_seconds, | |
| 296 | createdBy: row.created_by, | |
| 297 | createdAt: row.created_at, | |
| 298 | finishedAt: row.finished_at, | |
| 299 | }; | |
| 300 | } | |
| 301 | ||
| 302 | function toLive(app: AppRow): LiveApp { | |
| 303 | return { | |
| 304 | kind: app.kind, | |
| 305 | branch: app.branch, | |
| 306 | number: app.number, | |
| 307 | url: appUrl(app.script), | |
| 308 | commit: app.commit_sha, | |
| 309 | deployedAt: app.deployed_at, | |
| 310 | }; | |
| 311 | } | |
| 312 | ||
| 313 | class Deployments { | |
| 314 | constructor(private readonly env: Env) {} | |
| 315 | ||
| 316 | private get cloudflare(): Cloudflare | null { | |
| 317 | const token = this.env.CLOUDFLARE_API_TOKEN; | |
| 318 | return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null; | |
| 319 | } | |
| 320 | ||
| 321 | private get db() { | |
| 322 | return this.env.DB; | |
| 323 | } | |
| 324 | ||
| 325 | private get domains(): Domains { | |
| 326 | const token = this.env.CLOUDFLARE_API_TOKEN; | |
| 327 | const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID; | |
| 328 | return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null); | |
| 329 | } | |
| 330 | ||
| 331 | private get projects() { | |
| 332 | return projectsClient(this.env.PROJECTS); | |
| 333 | } | |
| 334 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 335 | /** A repository's deployments wherever they run: reported, from g1t Actions, and these builds. */ |
| 336 | get repoDeployments(): RepoDeployments { | |
| 337 | return new RepoDeployments(this.env); | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Publishes a build's change in the repository-wide model | |
| 342 | * (`deployment.created`, `deployment_status.created`), as a reported | |
| 343 | * deployment's are. Never fails the build. | |
| 344 | */ | |
| 345 | private async buildChanged(id: string, created = false): Promise<void> { | |
| 346 | try { | |
| 347 | const row = await this.deploymentRow(id); | |
| 348 | if (!row) return; | |
| 349 | const project = (await this.projects.byRepo(row.repo_id)).find((p) => p.id === row.project_id); | |
| 350 | const defaultBranch = project?.source.kind === "hosted" ? project.source.defaultBranch : "main"; | |
| 351 | await this.repoDeployments.buildChanged(row, defaultBranch, created); | |
| 352 | } catch (error) { | |
| 353 | console.error("build change not published", id, String(error)); | |
| 354 | } | |
| 355 | } | |
| 356 | ||
| The statement is a month at a time, a line per kind of charge | 357 | /** The workspace itself, as the service acts for it. */ |
| 358 | private async workspaceActor(slug: string): Promise<User | null> { | |
| 359 | const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug); | |
| 360 | if (!workspace) return null; | |
| 361 | return { | |
| 362 | id: workspace.id, | |
| 363 | username: workspace.slug, | |
| 364 | kind: "workspace", | |
| 365 | verified: true, | |
| 366 | workspaces: [{ slug: workspace.slug, role: "member" }], | |
| 367 | }; | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * What the project's secrets and variables available to deployments give | |
| 372 | * production or a preview: its build's environment, and the same again as | |
| 373 | * the running app's bindings. Untrusted builds get no secrets. | |
| 374 | */ | |
| 375 | private async resolve( | |
| 376 | project: { id: string; slug: string; repoId: string; repo: RepoPath }, | |
| 377 | environment: DeployKind, | |
| 378 | trusted: boolean, | |
| 379 | ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> { | |
| 380 | const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", { | |
| 381 | method: "POST", | |
| 382 | headers: { "content-type": "application/json" }, | |
| 383 | body: JSON.stringify({ | |
| 384 | repoId: project.repoId, | |
| 385 | repo: project.repo, | |
| 386 | projectId: project.id, | |
| 387 | projectSlug: project.slug, | |
| 388 | consumer: "deployments", | |
| 389 | environment, | |
| 390 | trusted, | |
| 391 | }), | |
| 392 | }); | |
| 393 | if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`); | |
| 394 | const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> }; | |
| 395 | return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables }; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 399 | * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for |
| 400 | * it, or its author) is trusted with the project's secrets: g1t itself, | |
| 401 | * or someone who can push to the repository (Write or more, a member's or | |
| 402 | * a collaborator's). Anyone else gets a preview built without them, as | |
| 403 | * their workflows run. A change g1t made for someone is trusted as they | |
| 404 | * are, never more for being g1t's. | |
| The statement is a month at a time, a line per kind of charge | 405 | */ |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 406 | private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> { |
| 407 | if (trustedOutright(owner)) return true; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 408 | const found = await identityClient(this.env.IDENTITY) |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 409 | .collaboratorPermission(actor, repo.namespace, repo.name, owner.username) |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 410 | .catch(() => null); |
| 411 | return !!found?.ok && allows(found.value.role, "push"); | |
| The statement is a month at a time, a line per kind of charge | 412 | } |
| 413 | ||
| 414 | private async settingsRow(projectId: string): Promise<SettingsRow | null> { | |
| 415 | return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>(); | |
| 416 | } | |
| 417 | ||
| 418 | /** The name an app gets, unique among apps: production, or a branch's preview. */ | |
| 419 | private async scriptFor(project: Project, branch: string | null): Promise<string> { | |
| 420 | const base = await label(project.workspace, project.slug, branch); | |
| 421 | const holder = await this.db | |
| 422 | .prepare( | |
| 423 | `SELECT project_id, branch FROM apps WHERE script = ?1 | |
| 424 | UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`, | |
| 425 | ) | |
| 426 | .bind(base) | |
| 427 | .first<{ project_id: string; branch: string | null }>(); | |
| 428 | if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base; | |
| 429 | return uniqueLabel(base, `${project.id}/${branch ?? ""}`); | |
| 430 | } | |
| 431 | ||
| 432 | private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> { | |
| 433 | return { | |
| 434 | enabled: !!row?.enabled, | |
| 435 | previews: row ? !!row.previews : true, | |
| 436 | production: row ? !!row.production : true, | |
| 437 | buildCommand: row?.build_command ?? null, | |
| 438 | outputDir: row?.output_dir ?? null, | |
| 439 | idleDays: row?.idle_days ?? 7, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 440 | viaWorkflow: !!row?.via_workflow, |
| The statement is a month at a time, a line per kind of charge | 441 | productionUrl: appUrl(await this.scriptFor(project, null)), |
| 442 | primaryDomain: await this.domains.primary(project.id).catch(() => null), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 443 | detected: await this.lastDetected(project.id), |
| The statement is a month at a time, a line per kind of charge | 444 | }; |
| 445 | } | |
| 446 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 447 | /** What the project's last finished build found it to be; null before one has. */ |
| 448 | private async lastDetected(projectId: string): Promise<DetectedKind | null> { | |
| 449 | const row = await this.db | |
| 450 | .prepare( | |
| 451 | "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1", | |
| 452 | ) | |
| 453 | .bind(projectId) | |
| 454 | .first<{ detected: string }>() | |
| 455 | .catch(() => null); | |
| 456 | return detectedKind(row?.detected); | |
| 457 | } | |
| 458 | ||
| 459 | /** | |
| 460 | * The project, if `viewer` may do what `method` needs on its repository | |
| 461 | * (see `NEEDS`): not found when they cannot read it, refused when they can | |
| 462 | * but their role is too low. | |
| 463 | */ | |
| 464 | private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> { | |
| 465 | const found = await this.projects.get(ref.workspace, ref.slug, viewer); | |
| 466 | if (!found.ok) return found; | |
| 467 | const repo = repoRef(found.value); | |
| 468 | if (!permission(viewer, repo)) return fail("not_found", "There is no such project."); | |
| 469 | const capability = NEEDS[method]; | |
| 470 | if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability)); | |
| 471 | return found; | |
| The statement is a month at a time, a line per kind of charge | 472 | } |
| 473 | ||
| 474 | // ---- Methods for the site and the API ------------------------------ | |
| 475 | ||
| 476 | async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 477 | const project = await this.projectFor(a.project, a.viewer, "settings"); |
| The statement is a month at a time, a line per kind of charge | 478 | if (!project.ok) return project; |
| 479 | return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id))); | |
| 480 | } | |
| 481 | ||
| 482 | async updateSettings(a: { | |
| 483 | actor: User; | |
| 484 | project: ProjectRef; | |
| 485 | changes: Partial<DeploySettings>; | |
| 486 | }): Promise<Result<DeploySettings>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 487 | const found = await this.projectFor(a.project, a.actor, "updateSettings"); |
| The statement is a month at a time, a line per kind of charge | 488 | if (!found.ok) return found; |
| 489 | const project = found.value; | |
| 490 | const before = await this.toSettings(project, await this.settingsRow(project.id)); | |
| 491 | const next = { ...before, ...a.changes }; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 492 | // A library, a tool or other, as set in its settings, does not deploy. |
| 493 | if (next.enabled && !before.enabled && project.setting?.kind && project.setting.kind !== "app" && project.setting.kind !== "docs") { | |
| 494 | return fail("conflict", "This project is set to be something that doesn't deploy. Change what it is in its General settings first."); | |
| A project is an app or a library: libraries show their package and how to ship a release, not production | 495 | } |
| The statement is a month at a time, a line per kind of charge | 496 | if (next.enabled && !before.enabled) { |
| 497 | // Turning it on starts paid work: only with the workspace's plan. | |
| 498 | const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments"); | |
| 499 | if (!plan.ok) return plan; | |
| 500 | } | |
| 501 | const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7))); | |
| 502 | const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null); | |
| 503 | await this.db | |
| 504 | .prepare( | |
| 505 | `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 506 | output_dir, idle_days, updated_by, updated_at, via_workflow) |
| 507 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) | |
| The statement is a month at a time, a line per kind of charge | 508 | ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6, |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 509 | production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12, |
| 510 | via_workflow = ?13`, | |
| The statement is a month at a time, a line per kind of charge | 511 | ) |
| 512 | .bind( | |
| 513 | project.id, | |
| 514 | project.workspace, | |
| 515 | project.slug, | |
| 516 | repoOf(project).id, | |
| 517 | next.enabled ? 1 : 0, | |
| 518 | next.previews ? 1 : 0, | |
| 519 | next.production ? 1 : 0, | |
| 520 | clip(next.buildCommand), | |
| 521 | clip(next.outputDir), | |
| 522 | idleDays, | |
| 523 | a.actor.username, | |
| 524 | now(), | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 525 | next.viaWorkflow ? 1 : 0, |
| The statement is a month at a time, a line per kind of charge | 526 | ) |
| 527 | .run(); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 528 | // The managed deploy workflow follows the settings: installed while the |
| 529 | // project builds through it, removed when it stops. | |
| 530 | if (next.viaWorkflow || before.viaWorkflow) { | |
| 531 | const installed = await this.syncManagedDeploy(project, next); | |
| 532 | if (!installed.ok) return installed; | |
| 533 | } | |
| A project is an app or a library: libraries show their package and how to ship a release, not production | 534 | // Projects keeps whether it deploys, so a project with Deployments on is an app. |
| 535 | if (next.enabled !== before.enabled) { | |
| 536 | await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error)); | |
| 537 | } | |
| The statement is a month at a time, a line per kind of charge | 538 | // What was turned off comes down now; nothing keeps running unasked. |
| 539 | if (!next.enabled) await this.takeDownWhere(project.id, null); | |
| 540 | else { | |
| 541 | if (!next.previews) await this.takeDownWhere(project.id, "preview"); | |
| 542 | if (!next.production) await this.takeDownWhere(project.id, "production"); | |
| 543 | } | |
| 544 | // Turned on: production goes up from the default branch at once. | |
| 545 | if (next.enabled && next.production && (!before.enabled || !before.production)) { | |
| 546 | await this.deployProduction(project, null, a.actor.username); | |
| 547 | } | |
| 548 | return ok(await this.toSettings(project, await this.settingsRow(project.id))); | |
| 549 | } | |
| 550 | ||
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 551 | /** |
| 552 | * Installs, replaces or removes the project's managed deploy workflow | |
| 553 | * (the actions service's `set_managed_deploy`) to match `settings`: it | |
| 554 | * runs while Deployments are on and the project builds through it. | |
| 555 | */ | |
| 556 | private async syncManagedDeploy(project: Project, settings: DeploySettings): Promise<Result<boolean>> { | |
| 557 | const on = settings.enabled && settings.viaWorkflow; | |
| 558 | const repo = repoOf(project); | |
| 559 | const response = await this.env.ACTIONS.fetch("https://actions/rpc/set_managed_deploy", { | |
| 560 | method: "POST", | |
| 561 | headers: { "content-type": "application/json" }, | |
| 562 | body: JSON.stringify({ | |
| 563 | repoId: repo.id, | |
| 564 | project: project.slug, | |
| 565 | productionBranch: repo.defaultBranch, | |
| 566 | rootDir: project.source.kind === "hosted" ? (project.source.rootDir ?? "") : "", | |
| 567 | buildCommand: settings.buildCommand ?? "", | |
| 568 | outputDir: settings.outputDir ?? "", | |
| 569 | previews: on && settings.previews, | |
| 570 | production: on && settings.production, | |
| 571 | }), | |
| 572 | }); | |
| 573 | const answer = (await response.json().catch(() => null)) as { ok?: boolean; value?: boolean; error?: { message?: string } } | null; | |
| 574 | if (!response.ok || !answer || answer.ok === false) { | |
| 575 | return fail("unavailable", answer?.error?.message ?? "The deploy workflow could not be installed. Try again in a moment."); | |
| 576 | } | |
| 577 | return ok(!!answer.value); | |
| 578 | } | |
| 579 | ||
| 580 | /** Whether `projectId` builds through its managed deploy workflow, so pushes and pull requests start no build here. */ | |
| 581 | private async buildsViaWorkflow(projectId: string): Promise<boolean> { | |
| 582 | return !!(await this.settingsRow(projectId))?.via_workflow; | |
| 583 | } | |
| 584 | ||
| A project is an app or a library: libraries show their package and how to ship a release, not production | 585 | /** For projects: whether Deployments are on for a project. Reads only this service's own table. */ |
| 586 | async isEnabled(a: { projectId: string }): Promise<boolean> { | |
| 587 | return !!(await this.settingsRow(a.projectId))?.enabled; | |
| 588 | } | |
| 589 | ||
| The statement is a month at a time, a line per kind of charge | 590 | async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 591 | const project = await this.projectFor(a.project, a.viewer, "list"); |
| The statement is a month at a time, a line per kind of charge | 592 | if (!project.ok) return project; |
| 593 | const [deployments, apps] = await Promise.all([ | |
| 594 | this.db | |
| 595 | .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?") | |
| 596 | .bind(project.value.id, LIST_LIMIT) | |
| 597 | .all<DeploymentRow>(), | |
| 598 | this.db | |
| 599 | .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC") | |
| 600 | .bind(project.value.id) | |
| 601 | .all<AppRow>(), | |
| 602 | ]); | |
| 603 | return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) }); | |
| 604 | } | |
| 605 | ||
| 606 | async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 607 | const project = await this.projectFor(a.project, a.viewer, "get"); |
| The statement is a month at a time, a line per kind of charge | 608 | if (!project.ok) return project; |
| 609 | const row = await this.db | |
| 610 | .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?") | |
| 611 | .bind(a.id, project.value.id) | |
| 612 | .first<DeploymentRow>(); | |
| 613 | if (!row) return fail("not_found", "No such deployment."); | |
| 614 | return ok({ ...toDeployment(row), log: row.log }); | |
| 615 | } | |
| 616 | ||
| 617 | async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 618 | const found = await this.projectFor(a.project, a.actor, "redeploy"); |
| The statement is a month at a time, a line per kind of charge | 619 | if (!found.ok) return found; |
| 620 | const project = found.value; | |
| 621 | const settings = await this.settingsRow(project.id); | |
| 622 | if (!settings?.enabled) return fail("conflict", "Deployments are off for this project."); | |
| 623 | if (a.branch == null) { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 624 | return notStarted((await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.")); |
| The statement is a month at a time, a line per kind of charge | 625 | } |
| 626 | // A branch's preview comes from its pull request. | |
| 627 | const app = await this.db | |
| 628 | .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC") | |
| 629 | .bind(project.id, a.branch) | |
| 630 | .first<{ number: number }>(); | |
| 631 | if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 632 | return notStarted((await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open.")); |
| The statement is a month at a time, a line per kind of charge | 633 | } |
| 634 | ||
| 635 | async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 636 | const project = await this.projectFor(a.project, a.actor, "takeDown"); |
| The statement is a month at a time, a line per kind of charge | 637 | if (!project.ok) return project; |
| 638 | await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined); | |
| 639 | return ok(true); | |
| 640 | } | |
| 641 | ||
| 642 | async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> { | |
| 643 | const workspace = a.workspace.toLowerCase(); | |
| 644 | if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments."); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 645 | // Only the projects whose repositories the viewer can read: the |
| 646 | // projects service lists no others. | |
| 647 | const listed = await this.projects.list(workspace, a.viewer); | |
| 648 | if (!listed.ok) return listed; | |
| 649 | const readable = new Set(listed.value.map((project) => project.slug)); | |
| The statement is a month at a time, a line per kind of charge | 650 | const [settings, apps, latest] = await Promise.all([ |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 651 | // A deleted repository's projects are hidden until it is restored. |
| 652 | this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ? AND repo_deleted_at IS NULL").bind(workspace).all<{ slug: string; enabled: number }>(), | |
| The statement is a month at a time, a line per kind of charge | 653 | this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(), |
| 654 | this.db | |
| 655 | .prepare( | |
| 656 | `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`, | |
| 657 | ) | |
| 658 | .bind(workspace) | |
| 659 | .all<DeploymentRow>(), | |
| 660 | ]); | |
| 661 | return ok( | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 662 | settings.results.filter((row) => readable.has(row.slug)).map((row) => { |
| The statement is a month at a time, a line per kind of charge | 663 | const own = apps.results.filter((app) => app.slug === row.slug); |
| 664 | const production = own.find((app) => app.kind === "production"); | |
| 665 | const newest = latest.results.find((d) => d.slug === row.slug); | |
| 666 | return { | |
| 667 | slug: row.slug, | |
| 668 | enabled: !!row.enabled, | |
| 669 | production: production ? toLive(production) : null, | |
| 670 | previews: own.filter((app) => app.kind === "preview").length, | |
| 671 | latest: newest ? toDeployment(newest) : null, | |
| 672 | }; | |
| 673 | }), | |
| 674 | ); | |
| 675 | } | |
| 676 | ||
| 677 | async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> { | |
| 678 | const slug = a.workspace.toLowerCase(); | |
| 679 | if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage."); | |
| 680 | const [meter, apps] = await Promise.all([ | |
| 681 | this.db | |
| 682 | .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?") | |
| 683 | .bind(slug, month()) | |
| 684 | .first<{ | |
| 685 | requests: number; | |
| 686 | cpu_ms: number; | |
| 687 | peak_apps: number; | |
| 688 | build_seconds: number; | |
| 689 | build_micros: number; | |
| 690 | counted_at: string | null; | |
| 691 | }>(), | |
| 692 | this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(), | |
| 693 | ]); | |
| 694 | return ok({ | |
| 695 | month: month(), | |
| 696 | requests: meter?.requests ?? 0, | |
| 697 | cpuMs: meter?.cpu_ms ?? 0, | |
| 698 | apps: apps?.n ?? 0, | |
| 699 | peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0), | |
| 700 | buildSeconds: meter?.build_seconds ?? 0, | |
| 701 | buildMicros: meter?.build_micros ?? 0, | |
| 702 | countedAt: meter?.counted_at ?? null, | |
| 703 | }); | |
| 704 | } | |
| 705 | ||
| 706 | // ---- Custom domains ------------------------------------------------ | |
| 707 | ||
| 708 | async listDomains(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<ProjectDomains>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 709 | const project = await this.projectFor(a.project, a.viewer, "listDomains"); |
| The statement is a month at a time, a line per kind of charge | 710 | if (!project.ok) return project; |
| 711 | const domains = this.domains; | |
| 712 | await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error)); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 713 | const [rows, available, used, costs] = await Promise.all([ |
| The statement is a month at a time, a line per kind of charge | 714 | domains.forProject(project.value.id), |
| 715 | domains.available(), | |
| 716 | domains.countFor(project.value.workspace), | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 717 | this.costs(), |
| The statement is a month at a time, a line per kind of charge | 718 | ]); |
| 719 | return ok({ | |
| 720 | domains: rows.map(toDomain), | |
| 721 | target: CUSTOM_DOMAIN_TARGET, | |
| 722 | available, | |
| 723 | notice: available ? null : NOT_ENABLED_NOTICE, | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 724 | monthlyMicros: costs.domainMonthPrice, |
| The statement is a month at a time, a line per kind of charge | 725 | used, |
| 726 | }); | |
| 727 | } | |
| 728 | ||
| 729 | async addDomain(a: { actor: User; project: ProjectRef; hostname: string; twin?: boolean }): Promise<Result<Domain[]>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 730 | const found = await this.projectFor(a.project, a.actor, "addDomain"); |
| The statement is a month at a time, a line per kind of charge | 731 | if (!found.ok) return found; |
| 732 | const project = found.value; | |
| 733 | const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments"); | |
| 734 | if (!plan.ok) return plan; | |
| 735 | const added = await this.domains.add({ | |
| 736 | project: { id: project.id, workspace: project.workspace, slug: project.slug }, | |
| 737 | script: await this.productionScript(project), | |
| 738 | hostname: String(a.hostname ?? ""), | |
| 739 | twin: !!a.twin, | |
| 740 | by: a.actor.username, | |
| 741 | }); | |
| 742 | if (!added.ok) return fail(added.code, added.message); | |
| 743 | await this.notePeak(project.workspace); | |
| 744 | return ok(added.rows.map(toDomain)); | |
| 745 | } | |
| 746 | ||
| 747 | async removeDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<true>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 748 | const found = await this.projectFor(a.project, a.actor, "removeDomain"); |
| The statement is a month at a time, a line per kind of charge | 749 | if (!found.ok) return found; |
| 750 | const row = await this.domains.byId(found.value.id, String(a.id ?? "")); | |
| 751 | if (!row) return fail("not_found", "No such domain."); | |
| 752 | await this.domains.remove(row); | |
| 753 | return ok(true); | |
| 754 | } | |
| 755 | ||
| 756 | async refreshDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<Domain>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 757 | const found = await this.projectFor(a.project, a.actor, "refreshDomain"); |
| The statement is a month at a time, a line per kind of charge | 758 | if (!found.ok) return found; |
| 759 | const domains = this.domains; | |
| 760 | const row = await domains.byId(found.value.id, String(a.id ?? "")); | |
| 761 | if (!row) return fail("not_found", "No such domain."); | |
| 762 | const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true); | |
| 763 | await this.notePeak(found.value.workspace); | |
| 764 | return ok(toDomain(after)); | |
| 765 | } | |
| 766 | ||
| 767 | /** The script production is up under, or the name it will have. */ | |
| 768 | private async productionScript(project: Project): Promise<string> { | |
| 769 | const app = await this.db | |
| 770 | .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'") | |
| 771 | .bind(project.id) | |
| 772 | .first<{ script: string }>(); | |
| 773 | return app?.script ?? (await this.scriptFor(project, null)); | |
| 774 | } | |
| 775 | ||
| 776 | // ---- Starting builds ----------------------------------------------- | |
| 777 | ||
| 778 | /** | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 779 | * Opens a deployment and starts its build in a sandbox. Skipped, with the |
| 780 | * reason recorded, when the workspace's plan is off. | |
| The statement is a month at a time, a line per kind of charge | 781 | */ |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 782 | private async start(input: OpenInput & { |
| 783 | /** Where the build reads the commit from: the project, or a pull request's fork. */ | |
| The statement is a month at a time, a line per kind of charge | 784 | source: RepoPath; |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 785 | /** Who reads it: the workspace, or whoever the pull request is for. */ |
| The statement is a month at a time, a line per kind of charge | 786 | reader: User; |
| 787 | }): Promise<Result<Deployment>> { | |
| 788 | const { project } = input; | |
| 789 | const repo = repoOf(project); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 790 | const opened = await this.open(input, { reserve: true }); |
| 791 | if (opened.refused !== null) return ok(toDeployment((await this.deploymentRow(opened.id))!)); | |
| 792 | const { build, compute } = opened; | |
| 793 | const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", { | |
| 794 | method: "POST", | |
| 795 | headers: { "content-type": "application/json" }, | |
| 796 | body: JSON.stringify({ | |
| 797 | deployId: opened.id, | |
| 798 | token: opened.token, | |
| 799 | workspace: project.workspace, | |
| 800 | reservation: compute.reservation, | |
| 801 | microsPerSecond: compute.microsPerSecond, | |
| 802 | maxRunMinutes: compute.maxRunMinutes, | |
| 803 | actor: input.reader, | |
| 804 | source: input.source, | |
| 805 | // The project, whose guardrails the build runs under: a preview's | |
| 806 | // source is its pull request's working copy, not the project. | |
| 807 | repo: repo.path, | |
| 808 | repoId: repo.id, | |
| 809 | commit: input.commit, | |
| 810 | rootDir: project.source.rootDir, | |
| 811 | buildCommand: input.settings.build_command, | |
| 812 | outputDir: input.settings.output_dir, | |
| 813 | buildEnv: build.variables, | |
| 814 | buildSecrets: build.secrets, | |
| 815 | }), | |
| 816 | }); | |
| 817 | const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`); | |
| 818 | if (!started.ok) { | |
| 819 | // Never reached a sandbox: what was reserved is given back. | |
| 820 | if (compute.reservation) await gateFor(this.env.BILLING).settle(compute.reservation, 0); | |
| 821 | await this.finishFailed(opened.id, started.error.message, null, null); | |
| 822 | } | |
| 823 | return ok(toDeployment((await this.deploymentRow(opened.id))!)); | |
| 824 | } | |
| 825 | ||
| 826 | /** | |
| 827 | * What every build does before anything builds it: checks the plan and | |
| 828 | * the spend limit (and, for a sandbox, reserves its compute with | |
| 829 | * billing), records the deployment with a token for that build alone, | |
| 830 | * replaces older builds of the same app, sets the commit's status, and | |
| 831 | * reads what the project's secrets and variables give builds of its | |
| 832 | * kind. A refusal is the deployment's status, saying what to do; nothing | |
| 833 | * is resolved for a refused build. | |
| 834 | */ | |
| 835 | private async open(input: OpenInput, options: { reserve: boolean }): Promise<Opened> { | |
| 836 | const { project } = input; | |
| 837 | const repo = repoOf(project); | |
| The statement is a month at a time, a line per kind of charge | 838 | const script = await this.scriptFor(project, input.branch); |
| 839 | const id = newId("dpl"); | |
| 840 | const token = randomToken(); | |
| 841 | const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments"); | |
| 842 | const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 843 | const stopped = limit.ok && limit.value.state === "stopped"; |
| The statement is a month at a time, a line per kind of charge | 844 | const cloudflare = this.cloudflare; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 845 | let refused = !plan.ok |
| The statement is a month at a time, a line per kind of charge | 846 | ? plan.error.message |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 847 | : stopped |
| The statement is a month at a time, a line per kind of charge | 848 | ? (limit.value.message ?? "The workspace reached its usage limit.") |
| 849 | : !cloudflare | |
| 850 | ? "Deployments are not set up on this g1t: it has no Cloudflare token." | |
| 851 | : null; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 852 | let code: Refusal = plan.ok && !stopped ? "conflict" : "payment_required"; |
| 853 | // A sandbox's build is compute: reserved with billing before it starts, | |
| 854 | // and settled by its sandbox when it stops. A workflow job's build runs | |
| 855 | // in the job, which pays for its own time. | |
| 856 | const compute: Compute = { reservation: null, microsPerSecond: 0, maxRunMinutes: null }; | |
| 857 | if (!refused && options.reserve) { | |
| 858 | const gate = gateFor(this.env.BILLING); | |
| 859 | const ent = await gate.entitlements(project.workspace); | |
| 860 | compute.microsPerSecond = await gate.microsPerSecond(); | |
| 861 | compute.maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 862 | const isPrivate = await reposClient(this.env.REPOS) |
| 863 | .get(repo.path, null) | |
| 864 | .then((found) => !found.ok || found.value.isPrivate) | |
| 865 | .catch(() => true); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 866 | const admitted = await gate.admit( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 867 | { |
| 868 | workspace: project.workspace, | |
| 869 | repo: repo.path, | |
| 870 | public: !isPrivate, | |
| 871 | kind: "deploy", | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 872 | estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, compute.maxRunMinutes ?? BUILD_MINUTES), compute.microsPerSecond), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 873 | }, |
| 874 | ent, | |
| 875 | ); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 876 | if (admitted.ok) compute.reservation = admitted.reservation?.id ?? null; |
| 877 | else { | |
| 878 | refused = admitted.message; | |
| 879 | code = "payment_required"; | |
| 880 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 881 | } |
| The statement is a month at a time, a line per kind of charge | 882 | await this.db |
| 883 | .prepare( | |
| 884 | `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 885 | script, status, error, token_hash, trusted, created_by, created_at, finished_at, run_id, job_id) |
| 886 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| The statement is a month at a time, a line per kind of charge | 887 | ) |
| 888 | .bind( | |
| 889 | id, | |
| 890 | project.id, | |
| 891 | project.workspace, | |
| 892 | project.slug, | |
| 893 | repo.id, | |
| 894 | `${repo.path.namespace}/${repo.path.name}`, | |
| 895 | input.kind, | |
| 896 | input.branch, | |
| 897 | input.number, | |
| 898 | input.commit, | |
| 899 | script, | |
| 900 | refused ? "skipped" : "queued", | |
| 901 | refused, | |
| 902 | refused ? null : await sha256(token), | |
| 903 | input.trusted ? 1 : 0, | |
| 904 | input.createdBy, | |
| 905 | now(), | |
| 906 | refused ? now() : null, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 907 | input.workflow?.runId ?? null, |
| 908 | input.workflow?.jobId ?? null, | |
| The statement is a month at a time, a line per kind of charge | 909 | ) |
| 910 | .run(); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 911 | if (refused) return { refused, code, id }; |
| The statement is a month at a time, a line per kind of charge | 912 | // Older builds of the same app are replaced by this one. |
| 913 | await this.db | |
| 914 | .prepare( | |
| 915 | `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ? | |
| 916 | WHERE script = ? AND id != ? AND status IN ('queued', 'building')`, | |
| 917 | ) | |
| 918 | .bind(now(), script, id) | |
| 919 | .run(); | |
| 920 | await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`); | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 921 | await this.buildChanged(id, true); |
| The statement is a month at a time, a line per kind of charge | 922 | // What the project's secrets and variables give builds of this kind. |
| 923 | const build = await this.resolve( | |
| 924 | { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path }, | |
| 925 | input.kind, | |
| 926 | input.trusted, | |
| 927 | ); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 928 | return { refused: null, id, token, script, build, compute }; |
| 929 | } | |
| 930 | ||
| 931 | /** | |
| 932 | * For a g1t Actions job (`POST /repos/{owner}/{repo}/deployments/builds`): | |
| 933 | * opens a g1t.page build of one of the repository's projects, which the | |
| 934 | * job builds in its own checkout with `g1t-runner deploy`. The caller is | |
| 935 | * the job's own token (`token.job`), which needs `deployments: write` and | |
| 936 | * reaches this repository only. The run decides what is built: a run on | |
| 937 | * the project's production branch builds production, a pull request's | |
| 938 | * run its preview, a run on another branch that branch's preview. A pull | |
| 939 | * request from a fork or from someone who cannot push here, and a run | |
| 940 | * that needed approval, get no build secrets. The job pays for its own | |
| 941 | * time, so nothing is reserved or charged here for the build; the plan | |
| 942 | * and the spend limit are checked as for any build. | |
| 943 | */ | |
| 944 | async startFromWorkflow(a: { actor: User; repo: RepoPath; project?: string | null; sha?: string | null }): Promise<Result<WorkflowBuild>> { | |
| 945 | const repoPath = { namespace: String(a.repo?.namespace ?? ""), name: String(a.repo?.name ?? "") }; | |
| 946 | const job = jobOf(a.actor, repoPath); | |
| 947 | if ("error" in job) return fail("forbidden", job.error); | |
| 948 | const actor = await this.workspaceActor(repoPath.namespace); | |
| 949 | if (!actor) return fail("not_found", "There is no such repository."); | |
| 950 | const found = await reposClient(this.env.REPOS).get(repoPath, actor); | |
| 951 | if (!found.ok) return fail("not_found", "There is no such repository."); | |
| 952 | const projects = await this.projects.byRepo(found.value.id); | |
| 953 | const rows = new Map(await Promise.all(projects.map(async (p) => [p.id, await this.settingsRow(p.id)] as const))); | |
| 954 | const live = (row: SettingsRow | null | undefined): row is SettingsRow => | |
| 955 | !!row?.enabled && !row.repo_deleted_at && !row.workspace_deleted_at; | |
| 956 | const chosen = projectNamed(projects, (p) => live(rows.get(p.id)), a.project); | |
| 957 | if ("error" in chosen) return fail(chosen.code, chosen.error); | |
| 958 | const project = chosen; | |
| 959 | if (project.source.kind !== "hosted") return fail("conflict", "Only projects hosted on g1t deploy so far."); | |
| 960 | // A token that writes deployments needs the role a redeploy does; one | |
| 961 | // that does not is let through below only for a managed preview. | |
| 962 | if (job.writes && !can(a.actor, repoRef(project), NEEDS.redeploy)) return fail("forbidden", needs(NEEDS.redeploy)); | |
| 963 | const settings = rows.get(project.id); | |
| 964 | if (!live(settings)) return fail("conflict", `Deployments are off for ${project.slug}.`); | |
| 965 | const repo = repoOf(project); | |
| 966 | ||
| 967 | // The run and its job, as the actions service has them: still running. | |
| 968 | const detail = await actionsClient(this.env.ACTIONS).run(repo.path, actor, job.runId); | |
| 969 | if (!detail.ok) return fail("not_found", "The job's run was not found."); | |
| 970 | const { run } = detail.value; | |
| 971 | const running = detail.value.jobs.find((j) => j.id === job.jobId); | |
| 972 | if (run.status === "completed" || !running || running.status === "completed") { | |
| 973 | return fail("conflict", "The job has finished: a build is opened from a job while it runs."); | |
| 974 | } | |
| 975 | const commit = commitOf(a.sha, run); | |
| 976 | if (typeof commit !== "string") return fail("invalid", commit.error); | |
| 977 | ||
| 978 | const target = targetOf(run, repo.defaultBranch); | |
| 979 | if ("error" in target) return fail("invalid", target.error); | |
| 980 | if (!job.writes && !readOnlyAllowed(run, target)) return fail("forbidden", NEEDS_WRITE); | |
| 981 | let kind: DeployKind = "production"; | |
| 982 | let branch: string | null = null; | |
| 983 | let number: number | null = null; | |
| 984 | let trusted = runTrusted({ approval: detail.value.approval, pull: null, insider: true }); | |
| 985 | if (target.kind === "pull") { | |
| 986 | const pull = await workClient(this.env.WORK).getPull(repo.path, target.number, actor); | |
| 987 | if (!pull.ok) return fail("not_found", `Pull request #${target.number} was not found.`); | |
| 988 | const open = pull.value.pull; | |
| 989 | if (open.status !== "open" && open.status !== "draft") { | |
| 990 | return fail("conflict", `Pull request #${target.number} is not open, so it has no preview.`); | |
| 991 | } | |
| 992 | kind = "preview"; | |
| 993 | // A pull request from a fork has no branch here (see `deployPreview`). | |
| 994 | branch = open.branch ?? `pr-${target.number}`; | |
| 995 | number = target.number; | |
| 996 | trusted = runTrusted({ | |
| 997 | approval: detail.value.approval, | |
| 998 | pull: open, | |
| 999 | insider: await this.insider(repo.path, workOwner(open), actor), | |
| 1000 | }); | |
| 1001 | } else if (target.kind === "branch") { | |
| 1002 | kind = "preview"; | |
| 1003 | branch = target.branch; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1004 | } |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1005 | // A read-only token's build never gets the project's build secrets. |
| 1006 | if (!job.writes) trusted = false; | |
| 1007 | if (kind === "production" && !settings.production) return fail("conflict", `Production deploys are off for ${project.slug}.`); | |
| 1008 | if (kind === "preview" && !settings.previews) return fail("conflict", `Previews are off for ${project.slug}.`); | |
| 1009 | ||
| 1010 | const opened = await this.open( | |
| 1011 | { project, kind, branch, number, commit, createdBy: run.actor ?? "g1t", settings, trusted, workflow: job }, | |
| 1012 | { reserve: false }, | |
| 1013 | ); | |
| 1014 | if (opened.refused !== null) return fail(opened.code, opened.refused); | |
| 1015 | return ok({ | |
| 1016 | deploy_id: opened.id, | |
| 1017 | token: opened.token, | |
| 1018 | kind, | |
| 1019 | project: project.slug, | |
| 1020 | branch, | |
| 1021 | number, | |
| 1022 | commit, | |
| 1023 | url: appUrl(opened.script), | |
| 1024 | trusted, | |
| 1025 | root_dir: project.source.rootDir ?? "", | |
| 1026 | build_command: settings.build_command, | |
| 1027 | output_dir: settings.output_dir, | |
| 1028 | build_env: opened.build.variables, | |
| 1029 | build_secrets: opened.build.secrets, | |
| 1030 | }); | |
| The statement is a month at a time, a line per kind of charge | 1031 | } |
| 1032 | ||
| 1033 | private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> { | |
| 1034 | const settings = await this.settingsRow(project.id); | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1035 | if (!settings?.enabled || !settings.production || settings.repo_deleted_at || settings.workspace_deleted_at) return null; |
| The statement is a month at a time, a line per kind of charge | 1036 | const actor = await this.workspaceActor(project.workspace); |
| 1037 | if (!actor) return null; | |
| 1038 | const repo = repoOf(project); | |
| 1039 | let head = commit; | |
| 1040 | if (!head) { | |
| 1041 | const branches = await reposClient(this.env.REPOS).branches(repo.path, actor); | |
| 1042 | head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null; | |
| 1043 | } | |
| 1044 | if (!head) return null; | |
| 1045 | return this.start({ | |
| 1046 | project, | |
| 1047 | kind: "production", | |
| 1048 | branch: null, | |
| 1049 | number: null, | |
| 1050 | commit: head, | |
| 1051 | source: repo.path, | |
| 1052 | reader: actor, | |
| 1053 | createdBy, | |
| 1054 | settings, | |
| 1055 | // The default branch only moves by people and agents with access. | |
| 1056 | trusted: true, | |
| 1057 | }); | |
| 1058 | } | |
| 1059 | ||
| 1060 | private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> { | |
| 1061 | const settings = await this.settingsRow(project.id); | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1062 | if (!settings?.enabled || !settings.previews || settings.repo_deleted_at || settings.workspace_deleted_at) return null; |
| The statement is a month at a time, a line per kind of charge | 1063 | const actor = await this.workspaceActor(project.workspace); |
| 1064 | if (!actor) return null; | |
| 1065 | const repo = repoOf(project); | |
| 1066 | const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor); | |
| 1067 | if (!detail.ok) return null; | |
| 1068 | const { pull } = detail.value; | |
| 1069 | if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null; | |
| 1070 | // A pull request from a fork (as g1t's agents work) has no branch here. | |
| 1071 | const branch = pull.branch ?? `pr-${number}`; | |
| 1072 | if (!force) { | |
| 1073 | // Already built, or being built, at this commit. | |
| 1074 | const same = await this.db | |
| 1075 | .prepare( | |
| 1076 | `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ? | |
| 1077 | AND status IN ('queued', 'building', 'ready')`, | |
| 1078 | ) | |
| 1079 | .bind(project.id, branch, pull.headCommit) | |
| 1080 | .first(); | |
| 1081 | if (same) return null; | |
| 1082 | } | |
| 1083 | return this.start({ | |
| 1084 | project, | |
| 1085 | kind: "preview", | |
| 1086 | branch, | |
| 1087 | number, | |
| 1088 | commit: pull.headCommit, | |
| 1089 | source: pull.fork ?? repo.path, | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 1090 | // The pull request's fork may be private: read it as whoever it is |
| 1091 | // for (whoever asked g1t for it, or its author), who is also who is | |
| 1092 | // trusted or not with the project's secrets. | |
| 1093 | reader: workOwner(pull), | |
| The statement is a month at a time, a line per kind of charge | 1094 | createdBy, |
| 1095 | settings, | |
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 1096 | trusted: await this.insider(repo.path, workOwner(pull), actor), |
| The statement is a month at a time, a line per kind of charge | 1097 | }); |
| 1098 | } | |
| 1099 | ||
| 1100 | // ---- A build's reports --------------------------------------------- | |
| 1101 | ||
| 1102 | private async deploymentRow(id: string): Promise<DeploymentRow | null> { | |
| 1103 | return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>(); | |
| 1104 | } | |
| 1105 | ||
| 1106 | /** The build, if `token` is its own and it is still under way. */ | |
| 1107 | private async building(id: string, token: unknown): Promise<DeploymentRow | null> { | |
| 1108 | const row = await this.deploymentRow(id); | |
| 1109 | if (!row?.token_hash || typeof token !== "string") return null; | |
| 1110 | if (row.token_hash !== (await sha256(token))) return null; | |
| 1111 | return row.status === "queued" || row.status === "building" ? row : null; | |
| 1112 | } | |
| 1113 | ||
| 1114 | async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> { | |
| 1115 | // Each report, for the logs: a build's own failure says why. | |
| 1116 | console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : ""); | |
| 1117 | const row = await this.building(id, body.token); | |
| 1118 | if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 }); | |
| 1119 | const cloudflare = this.cloudflare; | |
| 1120 | if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 }); | |
| 1121 | switch (step) { | |
| 1122 | case "started": | |
| 1123 | await this.db | |
| 1124 | .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?") | |
| 1125 | .bind(now(), id) | |
| 1126 | .run(); | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1127 | await this.buildChanged(id); |
| The statement is a month at a time, a line per kind of charge | 1128 | return Response.json(ok(true)); |
| 1129 | case "session": { | |
| 1130 | const manifest = body.manifest as Manifest | undefined; | |
| 1131 | if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 }); | |
| 1132 | const session = await cloudflare.openUpload(row.script, manifest); | |
| 1133 | return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl })); | |
| 1134 | } | |
| 1135 | case "finish": { | |
| 1136 | const worker = (body.worker ?? {}) as BuiltWorker; | |
| 1137 | const seconds = Number(body.buildSeconds) || 0; | |
| 1138 | const [namespace, name] = row.repo.split("/") as [string, string]; | |
| 1139 | try { | |
| 1140 | // Running apps' secrets and variables are bound here, by g1t: | |
| 1141 | // they never pass through the build's sandbox. | |
| 1142 | const runtime = await this.resolve( | |
| 1143 | { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } }, | |
| 1144 | row.kind, | |
| 1145 | !!row.trusted, | |
| 1146 | ); | |
| 1147 | await cloudflare.putScript( | |
| 1148 | row.script, | |
| 1149 | worker, | |
| 1150 | typeof body.completionJwt === "string" ? body.completionJwt : null, | |
| 1151 | [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind], | |
| 1152 | runtime, | |
| 1153 | ); | |
| 1154 | } catch (error) { | |
| 1155 | await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds); | |
| 1156 | return Response.json(ok(false)); | |
| 1157 | } | |
| 1158 | const at = now(); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1159 | const wasRedirect = await this.db.prepare("SELECT 1 FROM redirects WHERE script = ?").bind(row.script).first(); |
| The statement is a month at a time, a line per kind of charge | 1160 | await this.db.batch([ |
| 1161 | this.db | |
| 1162 | .prepare( | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1163 | `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ? |
| The statement is a month at a time, a line per kind of charge | 1164 | WHERE id = ?`, |
| 1165 | ) | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1166 | .bind( |
| 1167 | JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), | |
| 1168 | String(body.log ?? ""), | |
| 1169 | seconds, | |
| 1170 | at, | |
| 1171 | detectedKind(body.detected), | |
| 1172 | id, | |
| 1173 | ), | |
| The statement is a month at a time, a line per kind of charge | 1174 | // The build it replaces is no longer what the app serves. |
| 1175 | this.db | |
| 1176 | .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`) | |
| 1177 | .bind(row.script, id), | |
| 1178 | this.db | |
| 1179 | .prepare( | |
| 1180 | `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at) | |
| 1181 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9) | |
| 1182 | ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL`, | |
| 1183 | ) | |
| 1184 | .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at), | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1185 | // A name that redirected elsewhere (a move undone) is an app again. |
| The statement is a month at a time, a line per kind of charge | 1186 | this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script), |
| 1187 | ]); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1188 | if (wasRedirect) { |
| 1189 | await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error)); | |
| 1190 | } | |
| 1191 | // The same app at an older name (its project moved) now redirects | |
| 1192 | // here; it stays up as it was if the redirect cannot be put. | |
| 1193 | await this.supersede(cloudflare, row, row.script); | |
| The statement is a month at a time, a line per kind of charge | 1194 | // The project's own domains serve production wherever it is up. |
| 1195 | if (row.kind === "production") { | |
| 1196 | await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error)); | |
| 1197 | } | |
| 1198 | await this.chargeBuild(row, seconds); | |
| 1199 | await this.notePeak(row.workspace); | |
| 1200 | await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script)); | |
| Events: review requests, assignments, stops and deployments are published | 1201 | await this.announce(row, null); |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1202 | await this.buildChanged(id); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1203 | // A screenshot of production as it now is, for the project's overview. |
| 1204 | if (row.kind === "production") { | |
| 1205 | await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) => | |
| 1206 | console.error("could not ask for a screenshot", error), | |
| 1207 | ); | |
| 1208 | } | |
| The statement is a month at a time, a line per kind of charge | 1209 | return Response.json(ok(true)); |
| 1210 | } | |
| 1211 | case "fail": | |
| 1212 | await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null); | |
| 1213 | return Response.json(ok(true)); | |
| 1214 | default: | |
| 1215 | return Response.json(fail("not_found", "No such step."), { status: 404 }); | |
| 1216 | } | |
| 1217 | } | |
| 1218 | ||
| 1219 | /** | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1220 | * Older names of the app `script`, now up: one project's production, or |
| The statement is a month at a time, a line per kind of charge | 1221 | * its preview of one branch, has one name, so any other app row for the |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1222 | * same is the app under a name it had before its project moved (its |
| 1223 | * workspace renamed, its repository renamed or transferred). Each is | |
| 1224 | * replaced in the namespace by a redirect to the new name, its app row | |
| 1225 | * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for | |
| 1226 | * the sweep to hold the old script) and in `DOMAINS` under the old | |
| 1227 | * hostname, which the dispatcher follows before running anything, so the | |
| 1228 | * old address redirects even if the old script is paused. A name that | |
| 1229 | * cannot be redirected is left as it is, for the next deploy or sweep. | |
| The statement is a month at a time, a line per kind of charge | 1230 | */ |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1231 | private async supersede( |
| 1232 | cloudflare: Cloudflare, | |
| 1233 | app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string }, | |
| 1234 | script: string, | |
| 1235 | ): Promise<string[]> { | |
| The statement is a month at a time, a line per kind of charge | 1236 | const older = await this.db |
| 1237 | .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?") | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1238 | .bind(app.project_id, app.kind, app.branch, script) |
| The statement is a month at a time, a line per kind of charge | 1239 | .all<{ script: string }>(); |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1240 | const target = appHost(script); |
| The statement is a month at a time, a line per kind of charge | 1241 | const done: string[] = []; |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1242 | for (const { script: old } of older.results) { |
| The statement is a month at a time, a line per kind of charge | 1243 | try { |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1244 | await cloudflare.redirectScript(old, target); |
| The statement is a month at a time, a line per kind of charge | 1245 | } catch (error) { |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1246 | console.error("could not redirect", old, "to", script, error); |
| 1247 | continue; | |
| The statement is a month at a time, a line per kind of charge | 1248 | } |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1249 | const at = now(); |
| 1250 | const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString(); | |
| 1251 | await this.db.batch([ | |
| 1252 | this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old), | |
| 1253 | this.db | |
| 1254 | .prepare( | |
| 1255 | `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 1256 | ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`, | |
| 1257 | ) | |
| 1258 | .bind(old, target, app.workspace, at, expires), | |
| 1259 | // Its builds are no longer live under the old name. | |
| 1260 | this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old), | |
| 1261 | ]); | |
| 1262 | await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), { | |
| 1263 | expiration: Math.floor(Date.parse(expires) / 1000), | |
| 1264 | }).catch((error) => console.error("could not record redirect", old, error)); | |
| 1265 | done.push(old); | |
| The statement is a month at a time, a line per kind of charge | 1266 | } |
| 1267 | return done; | |
| 1268 | } | |
| 1269 | ||
| 1270 | private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> { | |
| 1271 | const row = await this.deploymentRow(id); | |
| 1272 | if (!row || (row.status !== "queued" && row.status !== "building")) return; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1273 | // A commit that is gone says so plainly; git's own words stay in the log. |
| 1274 | if (commitMissing(message)) { | |
| 1275 | log = log ?? message; | |
| 1276 | message = missingCommitMessage(row); | |
| 1277 | } | |
| The statement is a month at a time, a line per kind of charge | 1278 | await this.db |
| 1279 | .prepare( | |
| 1280 | `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ? | |
| 1281 | WHERE id = ?`, | |
| 1282 | ) | |
| 1283 | .bind(message.slice(0, 2000), log, seconds, now(), id) | |
| 1284 | .run(); | |
| 1285 | // A failed build still used its sandbox. | |
| 1286 | if (seconds) await this.chargeBuild(row, seconds); | |
| 1287 | await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`); | |
| Events: review requests, assignments, stops and deployments are published | 1288 | await this.announce(row, message.slice(0, 300)); |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1289 | await this.buildChanged(id); |
| Events: review requests, assignments, stops and deployments are published | 1290 | } |
| 1291 | ||
| 1292 | /** | |
| 1293 | * Publishes a finished build: `deployment.failed` with what went wrong, | |
| 1294 | * or `deployment.succeeded`, saying whether the build of the same app | |
| 1295 | * before it failed. Whoever started it is the actor when it was a | |
| 1296 | * person known by id; otherwise `triggeredBy` names them, or g1t. | |
| 1297 | */ | |
| 1298 | private async announce(row: DeploymentRow, error: string | null): Promise<void> { | |
| 1299 | if (!this.env.EVENTS) return; | |
| 1300 | const previous = error | |
| 1301 | ? null | |
| 1302 | : await this.db | |
| 1303 | .prepare( | |
| 1304 | `SELECT status FROM deployments | |
| 1305 | WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed') | |
| 1306 | ORDER BY created_at DESC LIMIT 1`, | |
| 1307 | ) | |
| 1308 | .bind(row.script, row.id, row.created_at) | |
| 1309 | .first<{ status: string }>(); | |
| 1310 | const byId = row.created_by.startsWith("usr_"); | |
| 1311 | const event = { | |
| 1312 | source: "deployments", | |
| 1313 | repoId: row.repo_id, | |
| 1314 | actor: byId ? row.created_by : null, | |
| 1315 | data: { | |
| 1316 | deploymentId: row.id, | |
| 1317 | projectId: row.project_id, | |
| 1318 | repoId: row.repo_id, | |
| 1319 | workspace: row.workspace, | |
| 1320 | project: row.slug, | |
| 1321 | kind: row.kind, | |
| 1322 | branch: row.branch, | |
| 1323 | number: row.kind === "preview" ? row.number : null, | |
| 1324 | commit: row.commit_sha, | |
| 1325 | path: `/${row.workspace}/${row.slug}/deployments/${row.id}`, | |
| 1326 | error, | |
| 1327 | recovered: previous?.status === "failed", | |
| 1328 | triggeredBy: byId ? "" : row.created_by, | |
| 1329 | }, | |
| 1330 | }; | |
| 1331 | await eventsClient(this.env.EVENTS) | |
| 1332 | .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }]) | |
| 1333 | .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason))); | |
| The statement is a month at a time, a line per kind of charge | 1334 | } |
| 1335 | ||
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1336 | /** |
| 1337 | * Each build is charged by the second, from the first, at the container | |
| 1338 | * price plus the margin. A build a workflow job ran is not: the job's | |
| 1339 | * time is charged as Actions time. | |
| 1340 | */ | |
| The statement is a month at a time, a line per kind of charge | 1341 | private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> { |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1342 | if (row.run_id) return; |
| The statement is a month at a time, a line per kind of charge | 1343 | const costs = await this.costs(); |
| 1344 | const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond); | |
| 1345 | if (cost <= 0) return; | |
| 1346 | const what = | |
| 1347 | row.kind === "preview" | |
| 1348 | ? `the ${row.branch} preview of ${row.workspace}/${row.slug}` | |
| 1349 | : `${row.workspace}/${row.slug} to production`; | |
| 1350 | await billingClient(this.env.BILLING).chargeFeature({ | |
| 1351 | workspace: row.workspace, | |
| 1352 | feature: "deployments", | |
| 1353 | costMicros: cost, | |
| 1354 | description: `Building ${what} (${Math.ceil(seconds)} s)`, | |
| 1355 | repo: row.repo, | |
| 1356 | reference: `deploy/${row.id}`, | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1357 | // Every second is metered; billing prices it from its price book and |
| 1358 | // tallies the month's build time. | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 1359 | buildSeconds: Math.ceil(seconds), |
| The statement is a month at a time, a line per kind of charge | 1360 | }); |
| 1361 | await this.db | |
| 1362 | .prepare( | |
| 1363 | `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4) | |
| 1364 | ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`, | |
| 1365 | ) | |
| 1366 | .bind(row.workspace, month(), Math.ceil(seconds), cost) | |
| 1367 | .run(); | |
| 1368 | } | |
| 1369 | ||
| 1370 | /** | |
| 1371 | * What each unit costs g1t now, from billing's price book, which follows | |
| 1372 | * what Cloudflare bills. The plan's figures if billing cannot say. | |
| 1373 | */ | |
| 1374 | private async costs(): Promise<{ | |
| 1375 | buildSecond: number; | |
| 1376 | millionRequests: number; | |
| 1377 | millionCpuMs: number; | |
| 1378 | domainMonth: number; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1379 | /** What one custom domain is charged a month: the cost plus the margin. */ |
| 1380 | domainMonthPrice: number; | |
| The statement is a month at a time, a line per kind of charge | 1381 | }> { |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1382 | const a = DEPLOYMENT_COSTS; |
| The statement is a month at a time, a line per kind of charge | 1383 | const book = await billingClient(this.env.BILLING) |
| 1384 | .prices() | |
| 1385 | .catch(() => null); | |
| 1386 | const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback; | |
| 1387 | return { | |
| 1388 | buildSecond: cost("build_second", a.microsPerBuildSecond), | |
| 1389 | millionRequests: cost("app_requests", a.microsPerMillionRequests), | |
| 1390 | millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs), | |
| 1391 | domainMonth: cost("custom_domain_month", a.microsPerDomainMonth), | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1392 | domainMonthPrice: |
| 1393 | book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2), | |
| The statement is a month at a time, a line per kind of charge | 1394 | }; |
| 1395 | } | |
| 1396 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1397 | /** Remembers the most apps (for information; never charged) and custom domains the workspace had at once this month. */ |
| The statement is a month at a time, a line per kind of charge | 1398 | private async notePeak(workspace: string): Promise<void> { |
| 1399 | await this.db | |
| 1400 | .prepare( | |
| 1401 | `INSERT INTO meters (namespace, month, peak_apps, peak_domains) | |
| 1402 | VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1), | |
| 1403 | (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL)) | |
| 1404 | ON CONFLICT (namespace, month) DO UPDATE SET | |
| 1405 | peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)), | |
| 1406 | peak_domains = MAX(peak_domains, (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))`, | |
| 1407 | ) | |
| 1408 | .bind(workspace, month()) | |
| 1409 | .run(); | |
| 1410 | } | |
| 1411 | ||
| 1412 | /** | |
| 1413 | * The check on the commit: `g1t / deploy`, or, for one of several | |
| 1414 | * projects on a repository, `g1t / deploy (<project>)`. | |
| 1415 | */ | |
| 1416 | private async status( | |
| 1417 | repoId: string, | |
| 1418 | sha: string, | |
| 1419 | project: { slug: string; primary: boolean }, | |
| 1420 | state: string, | |
| 1421 | description: string, | |
| 1422 | targetUrl: string, | |
| 1423 | ): Promise<void> { | |
| 1424 | const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`; | |
| 1425 | await this.env.WORK.fetch("https://work/rpc/set_commit_status", { | |
| 1426 | method: "POST", | |
| 1427 | headers: { "content-type": "application/json" }, | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1428 | body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }), |
| The statement is a month at a time, a line per kind of charge | 1429 | }).catch(() => undefined); |
| 1430 | } | |
| 1431 | ||
| 1432 | private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> { | |
| 1433 | const projects = await this.projects.byRepo(row.repo_id); | |
| 1434 | const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true; | |
| 1435 | await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl); | |
| 1436 | } | |
| 1437 | ||
| 1438 | // ---- Taking apps down ---------------------------------------------- | |
| 1439 | ||
| 1440 | private async removeApp(script: string): Promise<void> { | |
| 1441 | await this.cloudflare?.deleteScript(script); | |
| 1442 | await this.db.batch([ | |
| 1443 | this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script), | |
| 1444 | // Its build is no longer live anywhere. | |
| 1445 | this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script), | |
| 1446 | ]); | |
| 1447 | } | |
| 1448 | ||
| 1449 | private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> { | |
| 1450 | const apps = await this.db | |
| 1451 | .prepare( | |
| 1452 | `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`, | |
| 1453 | ) | |
| 1454 | .bind(projectId, kind, branch ?? null) | |
| 1455 | .all<{ script: string }>(); | |
| 1456 | for (const app of apps.results) await this.removeApp(app.script); | |
| 1457 | } | |
| 1458 | ||
| 1459 | // ---- Events -------------------------------------------------------- | |
| 1460 | ||
| 1461 | /** `attempts`: which delivery of the event this is, from 1. */ | |
| 1462 | async onEvent(event: G1tEvent, attempts = 1): Promise<void> { | |
| 1463 | switch (event.type) { | |
| 1464 | case "workspace.renamed": | |
| 1465 | await this.renamed(event.data, attempts); | |
| 1466 | break; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1467 | case "repo.transferred": |
| 1468 | case "repo.renamed": | |
| 1469 | await this.moved(repoMove(event)!, attempts); | |
| 1470 | break; | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1471 | // A repository that went or came back with its workspace is the |
| 1472 | // workspace's to handle: its apps are paused, not taken down. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1473 | case "repo.deleted": |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1474 | if (!event.data.withWorkspace) await this.repoDeleted(event.data.repoId); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1475 | break; |
| 1476 | case "repo.restored": | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1477 | if (!event.data.withWorkspace) await this.repoRestored(event.data.repoId, attempts); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1478 | break; |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1479 | case "workspace.deleting": |
| 1480 | await this.workspaceDeleting(event.data.slug); | |
| 1481 | break; | |
| 1482 | case "workspace.restored": | |
| 1483 | await this.workspaceRestored(event.data.slug); | |
| 1484 | break; | |
| 1485 | case "workspace.deleted": | |
| 1486 | await this.workspacePurged(event.data.slug); | |
| 1487 | break; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1488 | case "repo.purged": |
| 1489 | await this.repoPurged(event.data.repoId); | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1490 | await this.repoDeployments.purge(event.data.repoId); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1491 | break; |
| 1492 | case "repo.default_branch_changed": | |
| 1493 | await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t"); | |
| 1494 | break; | |
| 1495 | case "branch.renamed": | |
| 1496 | await this.branchRenamed(event.data.repoId, event.data.from, event.data.to); | |
| 1497 | break; | |
| 1498 | ||
| The statement is a month at a time, a line per kind of charge | 1499 | case "pull.opened": |
| 1500 | case "pull.ready": | |
| 1501 | case "pull.updated": | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1502 | case "pull.reopened": |
| The statement is a month at a time, a line per kind of charge | 1503 | for (const project of await this.projects.byRepo(event.data.repoId)) { |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1504 | // Its managed deploy workflow builds it instead (`syncManagedDeploy`). |
| 1505 | if (await this.buildsViaWorkflow(project.id)) continue; | |
| The statement is a month at a time, a line per kind of charge | 1506 | await this.deployPreview(project, event.data.number, "g1t"); |
| 1507 | } | |
| 1508 | break; | |
| 1509 | case "pull.closed": | |
| 1510 | case "pull.merged": | |
| 1511 | for (const project of await this.projects.byRepo(event.data.repoId)) { | |
| 1512 | const apps = await this.db | |
| 1513 | .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?") | |
| 1514 | .bind(project.id, event.data.number) | |
| 1515 | .all<{ script: string }>(); | |
| 1516 | for (const app of apps.results) await this.removeApp(app.script); | |
| 1517 | } | |
| 1518 | break; | |
| 1519 | case "git.push": | |
| 1520 | if (!event.data.defaultBranch) break; | |
| Merge branch 'mirroring' into artifacts-mode | 1521 | // A mirror deploys only while g1t leads it: standing by, or in CI |
| 1522 | // failover, the remote's own deploys stand (see contracts mirrors). | |
| 1523 | if (!mirrorWritable(event.data.mirror)) break; | |
| The statement is a month at a time, a line per kind of charge | 1524 | for (const project of await this.projects.byRepo(event.data.repoId)) { |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1525 | if (await this.buildsViaWorkflow(project.id)) continue; |
| The statement is a month at a time, a line per kind of charge | 1526 | await this.deployProduction(project, event.data.after, event.actor ?? "g1t"); |
| 1527 | } | |
| 1528 | break; | |
| 1529 | } | |
| 1530 | } | |
| 1531 | ||
| 1532 | /** | |
| 1533 | * A workspace's slug changed, and with it every app's name: production | |
| 1534 | * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`. | |
| 1535 | * | |
| 1536 | * Its rows move to the slug it has now (asked of identity, so a delivery | |
| 1537 | * twice over, or an older rename after a newer one, ends the same), and | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1538 | * each app (paused or not) is built again from the same commit under its |
| 1539 | * new name; see `followMoves`. The old name keeps serving the app until | |
| 1540 | * the new one is live; then it redirects to the new name (see | |
| 1541 | * `supersede`), held for as long as the workspace holds its old slug. | |
| The statement is a month at a time, a line per kind of charge | 1542 | * Builds under way for the old name are dropped and started again under |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1543 | * the new one. |
| The statement is a month at a time, a line per kind of charge | 1544 | */ |
| 1545 | private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> { | |
| 1546 | const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed); | |
| 1547 | const stale = staleSlugs(renamed, current); | |
| 1548 | if (stale.length === 0) return; | |
| 1549 | const marks = stale.map(() => "?").join(", "); | |
| 1550 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1551 | // The workspace's projects, under any of its names: a second delivery |
| 1552 | // finds them under the new one, with whatever is left to do. | |
| 1553 | const rows = await this.db | |
| 1554 | .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`) | |
| 1555 | .bind(...stale, current) | |
| 1556 | .all<{ project_id: string }>(); | |
| 1557 | const projectIds = rows.results.map((row) => row.project_id); | |
| 1558 | const projects = await this.projectsById(projectIds); | |
| 1559 | // The projects service hears of the rename on its own queue: wait for | |
| 1560 | // it a few deliveries, so the builds read the repository by its new name. | |
| 1561 | const behind = [...projects.values()].some((p) => p.workspace !== current); | |
| 1562 | if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`); | |
| The statement is a month at a time, a line per kind of charge | 1563 | |
| 1564 | // Every row moves at once. | |
| 1565 | const at = now(); | |
| 1566 | const statements: D1PreparedStatement[] = []; | |
| 1567 | for (const slug of stale) { | |
| 1568 | statements.push( | |
| 1569 | this.db | |
| 1570 | .prepare( | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1571 | `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`, |
| The statement is a month at a time, a line per kind of charge | 1572 | ) |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1573 | .bind(RENAMED_ERROR, at, slug), |
| The statement is a month at a time, a line per kind of charge | 1574 | this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug), |
| 1575 | this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug), | |
| 1576 | this.db | |
| 1577 | .prepare( | |
| 1578 | `UPDATE deployments SET workspace = ?1, | |
| 1579 | repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END | |
| 1580 | WHERE workspace = ?2`, | |
| 1581 | ) | |
| 1582 | .bind(current, slug), | |
| 1583 | this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug), | |
| 1584 | this.domains.rename(slug, current), | |
| 1585 | // Counters add up; the peak is the higher; a month charged stays charged. | |
| 1586 | this.db | |
| 1587 | .prepare( | |
| 1588 | `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at) | |
| 1589 | SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at | |
| 1590 | FROM meters WHERE namespace = ?2 | |
| 1591 | ON CONFLICT (namespace, month) DO UPDATE SET | |
| 1592 | requests = requests + excluded.requests, | |
| 1593 | cpu_ms = cpu_ms + excluded.cpu_ms, | |
| 1594 | peak_apps = MAX(peak_apps, excluded.peak_apps), | |
| 1595 | peak_domains = MAX(peak_domains, excluded.peak_domains), | |
| 1596 | build_seconds = build_seconds + excluded.build_seconds, | |
| 1597 | build_micros = build_micros + excluded.build_micros, | |
| 1598 | counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at), | |
| 1599 | charged_at = COALESCE(charged_at, excluded.charged_at)`, | |
| 1600 | ) | |
| 1601 | .bind(current, slug), | |
| 1602 | this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug), | |
| 1603 | ); | |
| 1604 | } | |
| 1605 | await this.db.batch(statements); | |
| 1606 | ||
| Projects no longer depend on each other: the dependsOn relation, its settings page (old links redirect), the overview card, a pull request's Affects panel and preview stacks, reference variables and agents' notes on what a project uses are gone; the table is dropped in a later deploy | 1607 | // Each app again, under its new name. |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1608 | const followed = await this.followMoves(projectIds, { |
| 1609 | projects, | |
| 1610 | adjust: (project) => this.underSlug(project, current, stale), | |
| 1611 | }); | |
| 1612 | if (followed.failed.length > 0) { | |
| 1613 | throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`); | |
| The statement is a month at a time, a line per kind of charge | 1614 | } |
| 1615 | } | |
| 1616 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1617 | /** |
| 1618 | * A repository moved: transferred to another workspace, and its projects | |
| 1619 | * with it, or renamed within its own, when its own project's slug follows | |
| 1620 | * its name (see the projects service). Each app's name is | |
| 1621 | * `<project>-<workspace>`, so the apps of every project whose workspace or | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1622 | * slug changed (production and every preview, paused or not) are built |
| 1623 | * again, from the same commit, under the new name; see `followMoves`. As | |
| 1624 | * after a workspace rename, the old name keeps serving until the new one | |
| 1625 | * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`. | |
| 1626 | * The project's custom domains follow its production. Builds under way | |
| 1627 | * are started again under the new name. What the apps used this month | |
| 1628 | * stays on the old workspace's meter; from now on, the new workspace's | |
| 1629 | * counts it. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1630 | * |
| 1631 | * Projects whose name did not change (a rename where the new name was | |
| 1632 | * taken, or a project of another name) only learn the repository's new | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1633 | * path. What is left to rebuild is read from the rows each time, so a |
| 1634 | * second or late delivery builds only what the first could not, and one | |
| 1635 | * whose rebuild could not be queued is delivered again (and, past the | |
| 1636 | * queue's retries, followed up by the sweep). | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1637 | */ |
| 1638 | private async moved(move: RepoMove, attempts: number): Promise<void> { | |
| 1639 | const current = await currentMovedPath(this.env.REPOS, move); | |
| 1640 | if (staleMovedPaths(move, current).length === 0) return; | |
| 1641 | const [workspace, name] = current.split("/") as [string, string]; | |
| 1642 | const settings = await this.db | |
| 1643 | .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?") | |
| 1644 | .bind(move.repoId) | |
| 1645 | .all<{ project_id: string; workspace: string; slug: string }>(); | |
| 1646 | if (settings.results.length === 0) return; | |
| 1647 | ||
| 1648 | // The projects service hears of the move on its own queue: wait for it | |
| 1649 | // a few deliveries, so builds read the project where and as it is now. | |
| 1650 | const projects = new Map<string, Project>(); | |
| 1651 | for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project); | |
| 1652 | const behind = settings.results.some(({ project_id }) => { | |
| 1653 | const project = projects.get(project_id); | |
| 1654 | if (!project || project.source.kind !== "hosted") return false; | |
| 1655 | return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current; | |
| 1656 | }); | |
| 1657 | if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`); | |
| 1658 | ||
| 1659 | // Its history goes with it, as the repository's issues do. | |
| 1660 | const statements: D1PreparedStatement[] = [ | |
| 1661 | this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId), | |
| 1662 | ]; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1663 | // The projects whose apps' names change, and have not been moved yet. |
| 1664 | const moving = settings.results | |
| 1665 | .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug) | |
| 1666 | .map((row) => row.project_id); | |
| 1667 | if (moving.length > 0) { | |
| 1668 | const ids = moving.map(() => "?").join(", "); | |
| 1669 | statements.push( | |
| 1670 | this.db | |
| 1671 | .prepare( | |
| 1672 | `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ? | |
| 1673 | WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`, | |
| 1674 | ) | |
| 1675 | .bind(MOVED_ERROR, now(), ...moving), | |
| 1676 | ); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1677 | } |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1678 | for (const projectId of moving) { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1679 | const slug = projects.get(projectId)?.slug ?? null; |
| 1680 | statements.push( | |
| 1681 | this.db | |
| 1682 | .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3") | |
| 1683 | .bind(workspace, slug, projectId), | |
| 1684 | this.db | |
| 1685 | .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3") | |
| 1686 | .bind(workspace, slug, projectId), | |
| 1687 | this.db | |
| 1688 | .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3") | |
| 1689 | .bind(workspace, slug, projectId), | |
| 1690 | // Within the workspace its apps are listed under the project's name | |
| 1691 | // now. One left behind in another workspace stays as it is until the | |
| 1692 | // new name is live and redirects it. | |
| 1693 | this.db | |
| 1694 | .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1") | |
| 1695 | .bind(workspace, slug, projectId), | |
| 1696 | ); | |
| 1697 | } | |
| 1698 | await this.db.batch(statements); | |
| 1699 | ||
| 1700 | // Each app again, under its new name. App rows under the old name stay | |
| 1701 | // until the new one is live, which redirects them. | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1702 | const followed = await this.followMoves( |
| 1703 | settings.results.map((row) => row.project_id), | |
| 1704 | { | |
| 1705 | projects, | |
| 1706 | adjust: (found) => | |
| 1707 | found.source.kind === "hosted" | |
| 1708 | ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } } | |
| 1709 | : { ...found, workspace }, | |
| 1710 | }, | |
| 1711 | ); | |
| 1712 | if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`); | |
| 1713 | } | |
| 1714 | ||
| 1715 | /** The projects with these ids, as the projects service has them now (found through their repositories). */ | |
| 1716 | private async projectsById(projectIds: string[]): Promise<Map<string, Project>> { | |
| 1717 | const projects = new Map<string, Project>(); | |
| 1718 | if (projectIds.length === 0) return projects; | |
| 1719 | const repoIds = new Set<string>(); | |
| 1720 | for (let i = 0; i < projectIds.length; i += 50) { | |
| 1721 | const chunk = projectIds.slice(i, i + 50); | |
| 1722 | const rows = await this.db | |
| 1723 | .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`) | |
| 1724 | .bind(...chunk) | |
| 1725 | .all<{ repo_id: string }>(); | |
| 1726 | for (const { repo_id } of rows.results) repoIds.add(repo_id); | |
| 1727 | } | |
| 1728 | const wanted = new Set(projectIds); | |
| 1729 | for (const repoId of repoIds) { | |
| 1730 | for (const project of await this.projects.byRepo(repoId)) { | |
| 1731 | if (wanted.has(project.id)) projects.set(project.id, project); | |
| 1732 | } | |
| 1733 | } | |
| 1734 | return projects; | |
| 1735 | } | |
| 1736 | ||
| 1737 | /** Whether `script` is the name an app of the project has where the project is now. */ | |
| 1738 | private async namedNow( | |
| 1739 | script: string, | |
| 1740 | settings: { project_id: string; workspace: string; slug: string }, | |
| 1741 | branch: string | null, | |
| 1742 | ): Promise<boolean> { | |
| 1743 | const base = await label(settings.workspace, settings.slug, branch); | |
| 1744 | return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`)); | |
| 1745 | } | |
| 1746 | ||
| 1747 | /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */ | |
| 1748 | private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> { | |
| 1749 | const stale: AppRow[] = []; | |
| 1750 | for (const app of apps) { | |
| 1751 | const row = settings.get(app.project_id); | |
| 1752 | if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app); | |
| 1753 | } | |
| 1754 | return stale; | |
| 1755 | } | |
| 1756 | ||
| 1757 | /** | |
| 1758 | * Brings the apps of the projects `projectIds` (every project when null) | |
| 1759 | * under the names they have where the projects are now: each app still | |
| 1760 | * under an older name, paused or not, and each build a move dropped, is | |
| 1761 | * built again under its new name from the same commit, and once that is | |
| 1762 | * live the old name redirects to it (`supersede`). | |
| 1763 | * | |
| 1764 | * Idempotent, and safe to run again at any time: an app already up under | |
| 1765 | * its new name only has its old names redirected; one whose rebuild is | |
| 1766 | * queued or under way is left to it; one whose workspace has no | |
| 1767 | * Deployments or is over its limit waits, without a refused deployment | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1768 | * each time; one whose commit is gone, or whose rebuild failed the same |
| 1769 | * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff` | |
| 1770 | * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`, | |
| 1771 | * doubled for each failure, waits. Returns what could not be queued, for an | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1772 | * event's delivery to be retried. |
| 1773 | */ | |
| 1774 | private async followMoves( | |
| 1775 | projectIds: string[] | null, | |
| 1776 | options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {}, | |
| 1777 | ): Promise<{ queued: number; waiting: number; failed: string[] }> { | |
| 1778 | const result = { queued: 0, waiting: 0, failed: [] as string[] }; | |
| 1779 | if (projectIds && projectIds.length === 0) return result; | |
| 1780 | const cloudflare = this.cloudflare; | |
| 1781 | if (!cloudflare) return result; | |
| 1782 | ||
| 1783 | const settingsRows = | |
| 1784 | projectIds == null | |
| 1785 | ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results | |
| 1786 | : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null); | |
| 1787 | const settings = new Map(settingsRows.map((row) => [row.project_id, row])); | |
| 1788 | if (settings.size === 0) return result; | |
| 1789 | const ids = [...settings.keys()]; | |
| 1790 | ||
| 1791 | const apps: AppRow[] = []; | |
| 1792 | const dropped: DroppedBuild[] = []; | |
| 1793 | for (let i = 0; i < ids.length; i += 50) { | |
| 1794 | const chunk = ids.slice(i, i + 50); | |
| 1795 | const marks = chunk.map(() => "?").join(", "); | |
| 1796 | const [appRows, droppedRows] = await Promise.all([ | |
| 1797 | this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(), | |
| 1798 | // Builds a move dropped, that nothing has been built in place of since. | |
| 1799 | this.db | |
| 1800 | .prepare( | |
| 1801 | `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?) | |
| 1802 | AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days') | |
| 1803 | AND NOT EXISTS ( | |
| 1804 | SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch | |
| 1805 | AND n.created_at > d.created_at AND n.status != 'skipped' | |
| 1806 | )`, | |
| 1807 | ) | |
| 1808 | .bind(...chunk, MOVED_ERROR, RENAMED_ERROR) | |
| 1809 | .all<DeploymentRow>(), | |
| 1810 | ]); | |
| 1811 | apps.push(...appRows.results); | |
| 1812 | dropped.push(...droppedRows.results); | |
| 1813 | } | |
| 1814 | const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped); | |
| 1815 | if (targets.length === 0) return result; | |
| 1816 | ||
| 1817 | const projects = new Map(options.projects ?? []); | |
| 1818 | const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id)); | |
| 1819 | for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project); | |
| 1820 | const billing = billingClient(this.env.BILLING); | |
| 1821 | const open = new Map<string, boolean>(); | |
| 1822 | const actors = new Map<string, User | null>(); | |
| 1823 | ||
| 1824 | for (const target of targets) { | |
| 1825 | const row = settings.get(target.projectId)!; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1826 | const found = projects.get(target.projectId); |
| 1827 | if (!found) continue; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1828 | const project = options.adjust ? options.adjust(found) : found; |
| 1829 | const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`; | |
| 1830 | // Built only where deployments has the project now; the projects | |
| 1831 | // service catches up on its own queue, and a later run builds then. | |
| 1832 | if (project.workspace !== row.workspace || project.slug !== row.slug) { | |
| 1833 | result.waiting++; | |
| 1834 | continue; | |
| 1835 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1836 | try { |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1837 | const script = await this.scriptFor(project, target.branch); |
| 1838 | // Already up under its new name: only its old names are left to redirect. | |
| 1839 | const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first(); | |
| 1840 | if (up) { | |
| 1841 | await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script); | |
| 1842 | continue; | |
| 1843 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1844 | const history = await this.recentBuilds(script); |
| 1845 | const last = history[0]; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1846 | if (last && (last.status === "queued" || last.status === "building")) { |
| 1847 | result.queued++; | |
| 1848 | continue; | |
| 1849 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1850 | // A commit that is gone, or a build that failed the same way a few |
| 1851 | // times, is not tried again; a push or a redeploy builds it. | |
| 1852 | // Otherwise the sweep waits longer after each failure. | |
| 1853 | if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff: options.backoff }).kind !== "build") { | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1854 | result.waiting++; |
| 1855 | continue; | |
| 1856 | } | |
| 1857 | // A workspace without Deployments, or over its limit, waits for it | |
| 1858 | // rather than gathering refused deployments. | |
| 1859 | if (!open.has(project.workspace)) { | |
| 1860 | const [plan, limit] = await Promise.all([ | |
| 1861 | billing.hasFeature(project.workspace, "deployments"), | |
| 1862 | billing.checkLimit(project.workspace), | |
| 1863 | ]); | |
| 1864 | open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped")); | |
| 1865 | } | |
| 1866 | if (!open.get(project.workspace)) { | |
| 1867 | result.waiting++; | |
| 1868 | continue; | |
| 1869 | } | |
| 1870 | if (!actors.has(project.workspace)) actors.set(project.workspace, await this.workspaceActor(project.workspace)); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1871 | const started = |
| 1872 | target.kind === "production" | |
| 1873 | ? await this.deployProduction(project, target.commit, "g1t") | |
| 1874 | : target.number != null | |
| 1875 | ? await this.deployPreview(project, target.number, "g1t", true) | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1876 | : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null); |
| 1877 | const outcome = rebuildOutcome(started); | |
| 1878 | if (outcome === "queued") result.queued++; | |
| 1879 | else if (outcome === "failed") { | |
| 1880 | const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : ""; | |
| 1881 | result.failed.push(`${named}: ${why}`); | |
| 1882 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1883 | } catch (error) { |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1884 | result.failed.push(`${named}: ${String(error)}`); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1885 | } |
| 1886 | } | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1887 | if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed); |
| 1888 | return result; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1889 | } |
| 1890 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1891 | /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */ |
| 1892 | private async recentBuilds(script: string): Promise<PastBuild[]> { | |
| 1893 | const rows = await this.db | |
| 1894 | .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?") | |
| 1895 | .bind(script, MAX_IDENTICAL_FAILURES) | |
| 1896 | .all<PastBuild>(); | |
| 1897 | return rows.results; | |
| 1898 | } | |
| 1899 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1900 | /** The projects built from a repository, as this service has them (projects hides a deleted one's). */ |
| 1901 | private async projectIdsFor(repoId: string): Promise<string[]> { | |
| 1902 | const rows = await this.db | |
| 1903 | .prepare("SELECT project_id FROM settings WHERE repo_id = ?") | |
| 1904 | .bind(repoId) | |
| 1905 | .all<{ project_id: string }>(); | |
| 1906 | return rows.results.map((row) => row.project_id); | |
| 1907 | } | |
| 1908 | ||
| 1909 | /** | |
| 1910 | * A repository was deleted, restorable for a while: every app of its | |
| 1911 | * projects (production and previews) comes down, builds under way are | |
| 1912 | * dropped, and nothing builds for it until it is restored. Its settings | |
| 1913 | * and custom domains are kept for the restore; until then a domain has | |
| 1914 | * nothing up to serve, as when production is turned off. | |
| 1915 | */ | |
| 1916 | private async repoDeleted(repoId: string): Promise<void> { | |
| 1917 | const projectIds = await this.projectIdsFor(repoId); | |
| 1918 | if (projectIds.length === 0) return; | |
| 1919 | const at = now(); | |
| 1920 | await this.db.batch([ | |
| 1921 | this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId), | |
| 1922 | this.db | |
| 1923 | .prepare( | |
| 1924 | `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ? | |
| 1925 | WHERE repo_id = ? AND status IN ('queued', 'building')`, | |
| 1926 | ) | |
| 1927 | .bind(at, repoId), | |
| 1928 | ]); | |
| 1929 | for (const projectId of projectIds) await this.takeDownWhere(projectId, null); | |
| 1930 | } | |
| 1931 | ||
| 1932 | /** | |
| 1933 | * A deleted repository is back: production goes up again from its | |
| 1934 | * default branch, for each project that has it on. Previews come back | |
| 1935 | * with the next push to their pull requests. | |
| 1936 | */ | |
| 1937 | private async repoRestored(repoId: string, attempts: number): Promise<void> { | |
| 1938 | const deleted = await this.db | |
| 1939 | .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL") | |
| 1940 | .bind(repoId) | |
| 1941 | .all<{ project_id: string }>(); | |
| 1942 | const ids = deleted.results.map((row) => row.project_id); | |
| 1943 | if (ids.length === 0) return; | |
| 1944 | // The projects service hears of the restore on its own queue, and hides | |
| 1945 | // the projects until then: wait for it a few deliveries. | |
| 1946 | const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id)); | |
| 1947 | if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`); | |
| 1948 | await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run(); | |
| 1949 | for (const project of projects) { | |
| 1950 | try { | |
| 1951 | const started = await this.deployProduction(project, null, "g1t"); | |
| 1952 | if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message); | |
| 1953 | } catch (error) { | |
| 1954 | console.error("could not deploy after restore", project.slug, error); | |
| 1955 | } | |
| 1956 | } | |
| 1957 | } | |
| 1958 | ||
| 1959 | /** | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1960 | * A workspace was deleted, restorable by g1t's staff for a while: every |
| 1961 | * app of its projects (production and previews) is paused, answering with | |
| 1962 | * a notice and running nothing, builds under way are dropped, and nothing | |
| 1963 | * builds for it until it is restored. Nothing is taken down: scripts, | |
| 1964 | * settings and custom domains are kept for the restore. Never for a | |
| 1965 | * protected workspace, whatever was published. | |
| 1966 | */ | |
| 1967 | private async workspaceDeleting(slug: string): Promise<void> { | |
| 1968 | const workspace = slug.toLowerCase(); | |
| 1969 | if (isProtectedWorkspace(workspace)) { | |
| 1970 | console.error("workspace.deleting ignored for protected", workspace); | |
| 1971 | return; | |
| 1972 | } | |
| 1973 | const at = now(); | |
| 1974 | await this.db.batch([ | |
| 1975 | this.db | |
| 1976 | .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?") | |
| 1977 | .bind(at, workspace), | |
| 1978 | this.db | |
| 1979 | .prepare( | |
| 1980 | `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ? | |
| 1981 | WHERE workspace = ? AND status IN ('queued', 'building')`, | |
| 1982 | ) | |
| 1983 | .bind(at, workspace), | |
| 1984 | ]); | |
| 1985 | const cloudflare = this.cloudflare; | |
| 1986 | for (const app of await this.appsOfWorkspace(workspace)) { | |
| 1987 | if (app.paused_at) continue; | |
| 1988 | await cloudflare?.pauseScript(app.script); | |
| 1989 | await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run(); | |
| 1990 | } | |
| 1991 | } | |
| 1992 | ||
| 1993 | /** | |
| 1994 | * A deleted workspace is back: it builds again, and its paused apps are | |
| 1995 | * resumed as the workspace's limit allows, as `holdToLimits` resumes any | |
| 1996 | * (the sweep tries again any it could not). | |
| 1997 | */ | |
| 1998 | private async workspaceRestored(slug: string): Promise<void> { | |
| 1999 | const workspace = slug.toLowerCase(); | |
| 2000 | await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run(); | |
| 2001 | const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>(); | |
| 2002 | const settings = new Map(rows.results.map((row) => [row.project_id, row])); | |
| 2003 | await this.holdToLimits(await this.appsOfWorkspace(workspace), settings); | |
| 2004 | } | |
| 2005 | ||
| 2006 | /** | |
| 2007 | * A deleted workspace is purged: whatever its projects still have up | |
| 2008 | * comes down and their custom domains go, as for a purged repository. | |
| 2009 | * Its own repositories' projects are purged with them (`repo.purged`); | |
| 2010 | * this catches any building from a repository it had transferred away. | |
| 2011 | */ | |
| 2012 | private async workspacePurged(slug: string): Promise<void> { | |
| 2013 | const workspace = slug.toLowerCase(); | |
| 2014 | if (isProtectedWorkspace(workspace)) return; | |
| 2015 | const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>(); | |
| 2016 | for (const { project_id } of rows.results) { | |
| 2017 | await this.takeDownWhere(project_id, null); | |
| 2018 | await this.domains.removeWhere("project_id", project_id); | |
| 2019 | } | |
| 2020 | for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script); | |
| 2021 | await this.db.batch([ | |
| 2022 | this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace), | |
| 2023 | this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace), | |
| 2024 | ]); | |
| 2025 | } | |
| 2026 | ||
| 2027 | /** The apps of a workspace's projects, and any still under its name. */ | |
| 2028 | private async appsOfWorkspace(workspace: string): Promise<AppRow[]> { | |
| 2029 | const rows = await this.db | |
| 2030 | .prepare( | |
| 2031 | `SELECT * FROM apps WHERE workspace = ?1 | |
| 2032 | OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`, | |
| 2033 | ) | |
| 2034 | .bind(workspace) | |
| 2035 | .all<AppRow>(); | |
| 2036 | return rows.results; | |
| 2037 | } | |
| 2038 | ||
| 2039 | /** | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 2040 | * A deleted repository is gone for good: its projects' custom domains are |
| 2041 | * removed (from the dispatcher and from Cloudflare), any app or redirect | |
| 2042 | * still up comes down, and every row kept for them goes. What they used | |
| 2043 | * stays on their workspace's meter. | |
| 2044 | */ | |
| 2045 | private async repoPurged(repoId: string): Promise<void> { | |
| 2046 | const projectIds = await this.projectIdsFor(repoId); | |
| 2047 | const domains = this.domains; | |
| 2048 | for (const projectId of projectIds) { | |
| 2049 | await this.takeDownWhere(projectId, null); | |
| 2050 | // One Cloudflare does not let go of yet is left `removing`, for the sweep. | |
| 2051 | await domains.removeWhere("project_id", projectId); | |
| 2052 | } | |
| 2053 | // The redirects left at names its apps had before. | |
| 2054 | const scripts = await this.db | |
| 2055 | .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?") | |
| 2056 | .bind(repoId) | |
| 2057 | .all<{ script: string }>(); | |
| 2058 | const hosts = scripts.results.map(({ script }) => appHost(script)); | |
| 2059 | for (let i = 0; i < hosts.length; i += 50) { | |
| 2060 | const chunk = hosts.slice(i, i + 50); | |
| 2061 | const redirects = await this.db | |
| 2062 | .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`) | |
| 2063 | .bind(...chunk) | |
| 2064 | .all<{ script: string }>(); | |
| 2065 | for (const { script } of redirects.results) { | |
| 2066 | await this.cloudflare?.deleteScript(script); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2067 | await this.env.DOMAINS?.delete(appHost(script)).catch((error) => console.error("could not drop redirect", script, error)); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 2068 | await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run(); |
| 2069 | } | |
| 2070 | } | |
| 2071 | await this.db.batch([ | |
| 2072 | this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId), | |
| 2073 | this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId), | |
| 2074 | ]); | |
| 2075 | } | |
| 2076 | ||
| 2077 | /** | |
| 2078 | * The default branch is another one now: production is built from it, as | |
| 2079 | * from a push to it, unless production already serves (or is building) | |
| 2080 | * its commit, as when the default branch was only renamed. | |
| 2081 | */ | |
| 2082 | private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> { | |
| 2083 | for (const found of await this.projects.byRepo(repoId)) { | |
| 2084 | if (found.source.kind !== "hosted") continue; | |
| 2085 | // Projects may not have heard yet: the event names the branch. | |
| 2086 | const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } }; | |
| 2087 | const actor = await this.workspaceActor(project.workspace); | |
| 2088 | if (!actor) continue; | |
| 2089 | const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor); | |
| 2090 | const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined; | |
| 2091 | if (!head) continue; | |
| 2092 | const same = await this.db | |
| 2093 | .prepare( | |
| 2094 | `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ? | |
| 2095 | AND status IN ('queued', 'building', 'ready')`, | |
| 2096 | ) | |
| 2097 | .bind(project.id, head) | |
| 2098 | .first(); | |
| 2099 | if (same) continue; | |
| 2100 | await this.deployProduction(project, head, createdBy); | |
| 2101 | } | |
| 2102 | } | |
| 2103 | ||
| 2104 | /** | |
| 2105 | * A branch was renamed: its preview is the same app, so its rows follow. | |
| 2106 | * The app keeps its name until it is next built; then it goes up under | |
| 2107 | * the new branch's name, and the old one redirects there (see `supersede`). | |
| 2108 | */ | |
| 2109 | private async branchRenamed(repoId: string, from: string, to: string): Promise<void> { | |
| 2110 | const projectIds = await this.projectIdsFor(repoId); | |
| 2111 | if (projectIds.length === 0) return; | |
| 2112 | const ids = projectIds.map(() => "?").join(", "); | |
| 2113 | await this.db.batch([ | |
| 2114 | this.db | |
| 2115 | .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`) | |
| 2116 | .bind(to, from, ...projectIds), | |
| 2117 | this.db | |
| 2118 | .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`) | |
| 2119 | .bind(to, from, ...projectIds), | |
| 2120 | ]); | |
| 2121 | } | |
| 2122 | ||
| The statement is a month at a time, a line per kind of charge | 2123 | /** `project` under the workspace's slug now, whether or not projects has caught up. */ |
| 2124 | private underSlug(project: Project, current: string, stale: string[]): Project { | |
| 2125 | const source = | |
| 2126 | project.source.kind === "hosted" && stale.includes(project.source.repo.namespace) | |
| 2127 | ? { ...project.source, repo: { ...project.source.repo, namespace: current } } | |
| 2128 | : project.source; | |
| 2129 | return { ...project, workspace: current, source }; | |
| 2130 | } | |
| 2131 | ||
| 2132 | /** A stack's preview (no pull request of its own) built again at `commit`. */ | |
| 2133 | private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> { | |
| 2134 | if (!actor || branch == null) return null; | |
| 2135 | const settings = await this.settingsRow(project.id); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 2136 | if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null; |
| The statement is a month at a time, a line per kind of charge | 2137 | return this.start({ |
| 2138 | project, | |
| 2139 | kind: "preview", | |
| 2140 | branch, | |
| 2141 | number: null, | |
| 2142 | commit, | |
| 2143 | source: repoOf(project).path, | |
| 2144 | reader: actor, | |
| 2145 | createdBy: "g1t", | |
| 2146 | settings, | |
| 2147 | // As `stack` built it: the project's own default branch. | |
| 2148 | trusted: true, | |
| 2149 | }); | |
| 2150 | } | |
| 2151 | ||
| 2152 | // ---- The sweep ----------------------------------------------------- | |
| 2153 | ||
| 2154 | /** | |
| 2155 | * Every few minutes: builds that died are failed; usage is counted; idle | |
| 2156 | * previews, the apps of workspaces whose plan ended, and scripts no app | |
| 2157 | * holds come down; and a month that is over is charged past its | |
| 2158 | * allowance. | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 2159 | * |
| 2160 | * Each step stands alone: one that fails is logged and the rest still | |
| 2161 | * run. Taking idle previews down and charging months, which only read | |
| 2162 | * g1t's own tables, go before the steps that wait on Cloudflare's API, | |
| 2163 | * so a slow or failing API never holds them up. | |
| The statement is a month at a time, a line per kind of charge | 2164 | */ |
| 2165 | async sweep(): Promise<void> { | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 2166 | const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error)); |
| 2167 | await step("failing stuck builds", async () => { | |
| 2168 | const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString(); | |
| 2169 | const stuck = await this.db | |
| 2170 | .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?") | |
| 2171 | .bind(cutoff) | |
| 2172 | .all<{ id: string }>(); | |
| 2173 | for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null); | |
| 2174 | }); | |
| 2175 | await step("taking down idle previews", () => this.takeDownIdle()); | |
| 2176 | await step("charging months", () => this.chargeMonths()); | |
| The statement is a month at a time, a line per kind of charge | 2177 | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2178 | let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results; |
| 2179 | // Each app is its project's workspace's, as deployments has it now: an | |
| 2180 | // app still under the name it had before its project moved is the new | |
| 2181 | // workspace's, and plans and limits are checked there. | |
| 2182 | const settings = new Map( | |
| 2183 | (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]), | |
| 2184 | ); | |
| 2185 | const owners = new Map([...settings].map(([id, row]) => [id, row.workspace])); | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 2186 | // A deleted workspace's apps stay paused as they are, for a restore: |
| 2187 | // its plan ended with the deletion, and that must not take them down. | |
| 2188 | const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at); | |
| 2189 | const live = apps.filter((app) => !held(app)); | |
| 2190 | const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))]; | |
| The statement is a month at a time, a line per kind of charge | 2191 | |
| 2192 | // Apps of workspaces whose plan has ended come down. | |
| 2193 | const billing = billingClient(this.env.BILLING); | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 2194 | await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error)); |
| The statement is a month at a time, a line per kind of charge | 2195 | for (const workspace of workspaces) { |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 2196 | await step(`ending ${workspace}'s apps`, async () => { |
| 2197 | const plan = await billing.hasFeature(workspace, "deployments"); | |
| 2198 | if (!plan.ok && plan.error.code === "payment_required") { | |
| 2199 | for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script); | |
| 2200 | // Custom domains cost g1t by the month: they go with the plan. | |
| 2201 | await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error)); | |
| 2202 | } | |
| 2203 | }); | |
| The statement is a month at a time, a line per kind of charge | 2204 | } |
| 2205 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2206 | // Apps whose project moved and are not up under the new name yet: a |
| 2207 | // move's rebuild that could not start is tried again here. | |
| 2208 | await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error)); | |
| 2209 | apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results; | |
| 2210 | ||
| The statement is a month at a time, a line per kind of charge | 2211 | await this.domains |
| 2212 | .sweep(async (projectId) => { | |
| 2213 | const app = await this.db | |
| 2214 | .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'") | |
| 2215 | .bind(projectId) | |
| 2216 | .first<{ script: string }>(); | |
| 2217 | return app?.script ?? null; | |
| 2218 | }) | |
| 2219 | .catch((error) => console.error("could not check domains", error)); | |
| 2220 | ||
| 2221 | await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error)); | |
| 2222 | await this.count(apps).catch((error) => console.error("could not count usage", error)); | |
| 2223 | } | |
| 2224 | ||
| 2225 | /** | |
| 2226 | * Pauses the apps of workspaces that reached their limit for usage not | |
| 2227 | * yet paid for, and rebuilds them from the same commit once they are | |
| 2228 | * under it again. Paused apps answer with a notice and run nothing. | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2229 | * |
| 2230 | * The workspace is the project's now (see `ownerOf`), never the one an | |
| 2231 | * app's row was written under, so an app left under its old name after | |
| 2232 | * a transfer is paused only if its new workspace is over its limit. Such | |
| 2233 | * an app is resumed by being built under its new name (`followMoves`), | |
| 2234 | * not here. | |
| The statement is a month at a time, a line per kind of charge | 2235 | */ |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2236 | private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> { |
| The statement is a month at a time, a line per kind of charge | 2237 | const cloudflare = this.cloudflare; |
| 2238 | if (!cloudflare) return; | |
| 2239 | const billing = billingClient(this.env.BILLING); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2240 | const owners = new Map([...settings].map(([id, row]) => [id, row.workspace])); |
| 2241 | for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) { | |
| The statement is a month at a time, a line per kind of charge | 2242 | const limit = await billing.checkLimit(workspace); |
| 2243 | if (!limit.ok) continue; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2244 | const theirs = apps.filter((app) => ownerOf(app, owners) === workspace); |
| The statement is a month at a time, a line per kind of charge | 2245 | if (limit.value.state === "stopped") { |
| 2246 | for (const app of theirs.filter((a) => !a.paused_at)) { | |
| 2247 | await cloudflare.pauseScript(app.script); | |
| 2248 | await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run(); | |
| 2249 | } | |
| 2250 | continue; | |
| 2251 | } | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2252 | const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script)); |
| 2253 | const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script)); | |
| The statement is a month at a time, a line per kind of charge | 2254 | if (paused.length === 0) continue; |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2255 | const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]); |
| The statement is a month at a time, a line per kind of charge | 2256 | for (const app of paused) { |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2257 | const project = projects.get(app.project_id); |
| 2258 | if (!project) continue; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 2259 | // A failed or refused rebuild leaves it paused, to try again later: |
| 2260 | // longer after each failure, and not once its commit is gone or it | |
| 2261 | // failed the same way a few times. | |
| 2262 | const history = await this.recentBuilds(app.script); | |
| 2263 | if (history[0]?.status === "queued" || history[0]?.status === "building") continue; | |
| 2264 | const backoff = history[0]?.status === "failed"; | |
| 2265 | if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue; | |
| The statement is a month at a time, a line per kind of charge | 2266 | const rebuilt = |
| 2267 | app.kind === "production" | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2268 | ? await this.deployProduction(project, app.commit_sha, "g1t") |
| The statement is a month at a time, a line per kind of charge | 2269 | : app.number != null |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2270 | ? await this.deployPreview(project, app.number, "g1t", true) |
| The statement is a month at a time, a line per kind of charge | 2271 | : null; |
| 2272 | if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message); | |
| 2273 | } | |
| 2274 | } | |
| 2275 | } | |
| 2276 | ||
| 2277 | /** | |
| 2278 | * Scripts in the namespace that no app holds, such as ones renamed. An | |
| 2279 | * old address that redirects to its app's new one is held until its | |
| 2280 | * redirect expires, then removed with the rest. | |
| 2281 | */ | |
| 2282 | private async removeOrphans(apps: AppRow[]): Promise<void> { | |
| 2283 | const cloudflare = this.cloudflare; | |
| 2284 | if (!cloudflare) return; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2285 | // Their entries in `DOMAINS` expire on their own, at the same time. |
| The statement is a month at a time, a line per kind of charge | 2286 | await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run(); |
| 2287 | const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>(); | |
| 2288 | const held = new Set([...apps.map((app) => app.script), ...redirects.results.map((r) => r.script)]); | |
| 2289 | const building = await this.db | |
| 2290 | .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')") | |
| 2291 | .all<{ script: string }>(); | |
| 2292 | for (const row of building.results) held.add(row.script); | |
| 2293 | const cutoff = Date.now() - ORPHAN_AFTER_MS; | |
| 2294 | for (const script of await cloudflare.listScripts()) { | |
| 2295 | if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id); | |
| 2296 | } | |
| 2297 | } | |
| 2298 | ||
| 2299 | /** Counts this month's requests and CPU time per workspace, from analytics. */ | |
| 2300 | private async count(apps: AppRow[]): Promise<void> { | |
| 2301 | const cloudflare = this.cloudflare; | |
| 2302 | if (!cloudflare || apps.length === 0) return; | |
| 2303 | const start = `${month()}-01T00:00:00Z`; | |
| 2304 | const totals = await cloudflare.usage(apps.map((app) => app.script), start, now()); | |
| 2305 | // Analytics only counts apps that are up; the meter keeps what earlier | |
| 2306 | // apps used by never going down. | |
| 2307 | const perWorkspace = new Map<string, { requests: number; cpuMs: number }>(); | |
| 2308 | for (const app of apps) { | |
| 2309 | const used = totals.get(app.script); | |
| 2310 | if (!used) continue; | |
| 2311 | const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 }; | |
| 2312 | sum.requests += used.requests; | |
| 2313 | sum.cpuMs += used.cpuMs; | |
| 2314 | perWorkspace.set(app.workspace, sum); | |
| 2315 | } | |
| 2316 | const at = now(); | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 2317 | // Written only when the count moves the meter: most sweeps it does not. |
| 2318 | const stored = new Map( | |
| 2319 | ( | |
| 2320 | await this.db | |
| 2321 | .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?") | |
| 2322 | .bind(month()) | |
| 2323 | .all<{ namespace: string; requests: number; cpu_ms: number }>() | |
| 2324 | ).results.map((row) => [row.namespace, row]), | |
| 2325 | ); | |
| The statement is a month at a time, a line per kind of charge | 2326 | for (const [workspace, used] of perWorkspace) { |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 2327 | if (!movesMeter(stored.get(workspace), used)) continue; |
| The statement is a month at a time, a line per kind of charge | 2328 | await this.db |
| 2329 | .prepare( | |
| 2330 | `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 2331 | ON CONFLICT (namespace, month) DO UPDATE SET | |
| 2332 | requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`, | |
| 2333 | ) | |
| 2334 | .bind(workspace, month(), used.requests, used.cpuMs, at) | |
| 2335 | .run(); | |
| 2336 | } | |
| 2337 | // When each preview last answered anyone, for the idle sweep. | |
| 2338 | const recent = await cloudflare.usage( | |
| 2339 | apps.filter((app) => app.kind === "preview").map((app) => app.script), | |
| 2340 | new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), | |
| 2341 | at, | |
| 2342 | ); | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 2343 | // At an hour's resolution: previews idle for days are what it finds. |
| 2344 | const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); | |
| 2345 | const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at])); | |
| The statement is a month at a time, a line per kind of charge | 2346 | for (const [script, used] of recent) { |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 2347 | const last = lastRequest.get(script); |
| 2348 | if (used.requests > 0 && (!last || last < hourAgo)) { | |
| The statement is a month at a time, a line per kind of charge | 2349 | await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run(); |
| 2350 | } | |
| 2351 | } | |
| 2352 | for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2353 | // What this month's traffic and custom domains will cost, from the |
| 2354 | // first request and the first domain, so the workspace's limit counts | |
| 2355 | // it now rather than when the month closes, and its Billing page shows it. | |
| The statement is a month at a time, a line per kind of charge | 2356 | const costs = await this.costs(); |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2357 | const billing = billingClient(this.env.BILLING); |
| 2358 | const meters = await this.db | |
| 2359 | .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?") | |
| 2360 | .bind(month()) | |
| 2361 | .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>(); | |
| 2362 | for (const meter of meters.results) { | |
| 2363 | const cost = monthCost(meter, costs); | |
| 2364 | await billing | |
| 2365 | .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail) | |
| The statement is a month at a time, a line per kind of charge | 2366 | .catch((error) => console.error("could not note pending usage", error)); |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2367 | if ((meter.peak_domains ?? 0) > 0) { |
| 2368 | await billing | |
| 2369 | .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail) | |
| 2370 | .catch((error) => console.error("could not note pending usage", error)); | |
| 2371 | } | |
| The statement is a month at a time, a line per kind of charge | 2372 | } |
| 2373 | } | |
| 2374 | ||
| 2375 | /** Previews no one has visited in their project's idle days. */ | |
| 2376 | private async takeDownIdle(): Promise<void> { | |
| 2377 | const idle = await this.db | |
| 2378 | .prepare( | |
| 2379 | `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 2380 | WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL |
| The statement is a month at a time, a line per kind of charge | 2381 | AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`, |
| 2382 | ) | |
| 2383 | .all<{ script: string }>(); | |
| 2384 | for (const { script } of idle.results) await this.removeApp(script); | |
| 2385 | } | |
| 2386 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2387 | /** Charges each month that is over for its requests, CPU time and custom domains, from the first, once. */ |
| The statement is a month at a time, a line per kind of charge | 2388 | private async chargeMonths(): Promise<void> { |
| 2389 | const due = await this.db | |
| 2390 | .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL") | |
| 2391 | .bind(month()) | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2392 | .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>(); |
| The statement is a month at a time, a line per kind of charge | 2393 | const costs = await this.costs(); |
| 2394 | for (const meter of due.results) { | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2395 | const cost = monthCost(meter, costs); |
| 2396 | if (cost.micros > 0) { | |
| The statement is a month at a time, a line per kind of charge | 2397 | const charged = await billingClient(this.env.BILLING).chargeFeature({ |
| 2398 | workspace: meter.namespace, | |
| 2399 | feature: "deployments", | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2400 | costMicros: cost.micros, |
| 2401 | description: `Deployments in ${meter.month}: ${cost.description}`, | |
| The statement is a month at a time, a line per kind of charge | 2402 | reference: `deployments/${meter.namespace}/${meter.month}`, |
| 2403 | }); | |
| 2404 | if (!charged.ok) continue; | |
| 2405 | } | |
| 2406 | await this.db | |
| 2407 | .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?") | |
| 2408 | .bind(now(), meter.namespace, meter.month) | |
| 2409 | .run(); | |
| 2410 | } | |
| 2411 | } | |
| 2412 | } | |
| 2413 | ||
| 2414 | /** `POST /rpc/<method>`: the arguments are the body. */ | |
| 2415 | async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> { | |
| 2416 | switch (method) { | |
| 2417 | case "settings": | |
| 2418 | return service.settings(args); | |
| A project is an app or a library: libraries show their package and how to ship a release, not production | 2419 | case "is_enabled": |
| 2420 | return service.isEnabled(args); | |
| The statement is a month at a time, a line per kind of charge | 2421 | case "update_settings": |
| 2422 | return service.updateSettings(args); | |
| 2423 | case "list": | |
| 2424 | return service.list(args); | |
| 2425 | case "get": | |
| 2426 | return service.get(args); | |
| 2427 | case "redeploy": | |
| 2428 | return service.redeploy(args); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 2429 | // For the API: a g1t Actions job opening a build it runs itself. |
| 2430 | case "start_from_workflow": | |
| 2431 | return service.startFromWorkflow(args); | |
| The statement is a month at a time, a line per kind of charge | 2432 | case "take_down": |
| 2433 | return service.takeDown(args); | |
| 2434 | case "overview": | |
| 2435 | return service.overview(args); | |
| 2436 | case "usage": | |
| 2437 | return service.usage(args); | |
| 2438 | case "domains": | |
| 2439 | return service.listDomains(args); | |
| 2440 | case "add_domain": | |
| 2441 | return service.addDomain(args); | |
| 2442 | case "remove_domain": | |
| 2443 | return service.removeDomain(args); | |
| 2444 | case "refresh_domain": | |
| 2445 | return service.refreshDomain(args); | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 2446 | // A repository's deployments wherever they run (repo-deployments.ts). |
| 2447 | case "list_deployments": | |
| 2448 | return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" }); | |
| 2449 | case "get_deployment": | |
| 2450 | return service.repoDeployments.get(args); | |
| 2451 | case "list_deployment_statuses": | |
| 2452 | return service.repoDeployments.statuses(args); | |
| 2453 | case "list_environments": | |
| 2454 | return service.repoDeployments.environments(args); | |
| 2455 | case "get_environment": | |
| 2456 | return service.repoDeployments.environment(args); | |
| 2457 | case "create_deployment": | |
| 2458 | return service.repoDeployments.create(args); | |
| 2459 | case "create_deployment_status": | |
| 2460 | return service.repoDeployments.createStatus(args); | |
| 2461 | // For the actions service: a run's deployment to one environment. | |
| 2462 | case "actions_deployment": | |
| 2463 | return service.repoDeployments.fromActions(args); | |
| The statement is a month at a time, a line per kind of charge | 2464 | default: |
| 2465 | return undefined; | |
| 2466 | } | |
| 2467 | } | |
| 2468 | ||
| 2469 | export default { | |
| 2470 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 2471 | const { pathname } = new URL(request.url); | |
| 2472 | if (request.method !== "POST") return new Response("Not found\n", { status: 404 }); | |
| 2473 | const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; | |
| 2474 | const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/); | |
| 2475 | if (rpcMatch) { | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2476 | // A replica near the caller when it asks for one (@g1t/contracts d1.ts). |
| 2477 | const opened = openD1(env.DB, request); | |
| 2478 | const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env); | |
| The statement is a month at a time, a line per kind of charge | 2479 | const result = await rpc(service, rpcMatch[1], body, ctx); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2480 | return opened.finish(result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result)); |
| The statement is a month at a time, a line per kind of charge | 2481 | } |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2482 | const service = new Deployments(env); |
| The statement is a month at a time, a line per kind of charge | 2483 | // A build's reports, forwarded by the API. |
| 2484 | const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/); | |
| 2485 | if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body); | |
| 2486 | return new Response("Not found\n", { status: 404 }); | |
| 2487 | }, | |
| 2488 | ||
| 2489 | async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> { | |
| 2490 | const service = new Deployments(env); | |
| 2491 | for (const message of batch.messages) { | |
| 2492 | try { | |
| 2493 | await service.onEvent(message.body, message.attempts); | |
| 2494 | message.ack(); | |
| 2495 | } catch (error) { | |
| 2496 | console.error("deployments could not handle", message.body.type, error); | |
| 2497 | message.retry(); | |
| 2498 | } | |
| 2499 | } | |
| 2500 | }, | |
| 2501 | ||
| 2502 | async scheduled(_controller: ScheduledController, env: Env): Promise<void> { | |
| 2503 | await new Deployments(env).sweep(); | |
| 2504 | }, | |
| 2505 | } satisfies ExportedHandler<Env, G1tEvent>; |
This file's history is long; its oldest lines are credited to the oldest commit read.