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.
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 1 | #!/usr/bin/env node |
| 2 | // Deploys g1t to Cloudflare from deploy/stack.jsonc: only what changed since | |
| 3 | // each Worker's live commit, migrations first, then stage by stage. | |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 4 | // docs.g1t.sh/guides/deploy-to-cloudflare/ is the guide. |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 5 | // |
| 6 | // node scripts/deploy.mjs plan what would deploy, and why (read-only) | |
| 7 | // node scripts/deploy.mjs deploy migrations, then every changed unit | |
| 8 | // node scripts/deploy.mjs deploy --only web,api just these (if changed; --force: anyway) | |
| 9 | // node scripts/deploy.mjs build --only events build as a deploy would, upload nothing | |
| 10 | // node scripts/deploy.mjs migrate pending D1 migrations only | |
| 11 | // node scripts/deploy.mjs manifest [--check] the resolved manifest, or its problems | |
| 12 | // node scripts/deploy.mjs doctor which units lack their secrets | |
| 13 | // node scripts/deploy.mjs install --only a,b npm ci of just what those units need (CI) | |
| 14 | // node scripts/deploy.mjs build-base build and push the runner's base image (Docker) | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 15 | // node scripts/deploy.mjs image make the runner's image for this checkout in the registry (--no-push: build it with Docker) |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 16 | // |
| 17 | // Flags: --all (every unit, changed or not), --only a,b, --skip a,b, | |
| 18 | // --force, --concurrency N (default 4), --stage core (one stage), | |
| 19 | // --json (plan), --out FILE (plan), --no-migrations, --allow-dirty, | |
| 20 | // --rebuild-image, --rebuild-base, --since REV (Workers with no recorded | |
| 21 | // commit are taken to run REV); for build-base and image, --no-push, and | |
| 22 | // for build-base, --no-cache. | |
| 23 | ||
| 24 | import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; | |
| 25 | import { tmpdir } from "node:os"; | |
| 26 | import { join } from "node:path"; | |
| 27 | ||
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 28 | import { BINARY, OUT_DIR } from "./build-runner.mjs"; |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 29 | import { ensureWorkerBuild } from "./build-rust-worker.mjs"; |
| 30 | import { | |
| 31 | annotation, | |
| Merge a faster deploy plan: Cloudflare's API read directly and in parallel (2 s against 4 min), and the plan runs beside the check | 32 | apiAuth, |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 33 | applyMigrations, |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 34 | DEPLOY_RETRY_WAITS_MS, |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 35 | dockerAvailable, |
| 36 | exec, | |
| 37 | jsonFrom, | |
| 38 | lastLines, | |
| 39 | pendingMigrations, | |
| 40 | readLive, | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 41 | retryableDeploy, |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 42 | versionFrom, |
| 43 | wrangler, | |
| 44 | wranglerEnv, | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 45 | wranglerError, |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 46 | } from "./deploy/cloudflare.mjs"; |
| 47 | import { | |
| 48 | baseInputs, | |
| 49 | baseState, | |
| 50 | baseTag, | |
| 51 | baseVersions, | |
| 52 | buildBase, | |
| 53 | buildRunnerImage, | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 54 | diskReport, |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 55 | dockerHas, |
| 56 | dockerLogin, | |
| 57 | imageSize, | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 58 | makeRunnerImage, |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 59 | pushImage, |
| 60 | readBaseLock, | |
| 61 | registryHas, | |
| 62 | removeDeployConfig, | |
| 63 | runnerRef, | |
| 64 | writeBaseLock, | |
| 65 | writeDeployConfig, | |
| 66 | } from "./deploy/image.mjs"; | |
| 67 | import { decide, git, planJson, pool, table } from "./deploy/plan.mjs"; | |
| Deploying: a deploy by hand shows on the repository's Deployments page | 68 | import { reportDeployment } from "./deploy/report.mjs"; |
| A deploy that changes the runner's image or its container settings rolls every idle sandbox over at once (--containers-rollout=immediate), where Cloudflare's default rollout went in steps over wall-clock time and, with deploys often half an hour apart, never reached every sandbox: jobs kept starting on old images and old machine sizes. A change to the container settings alone, such as the instance type, now rolls out too; before, an unchanged image meant --containers-rollout none and the new settings were never applied. Busy sandboxes still get rollout_active_grace_period to finish. | 69 | import { ROOT, byStage, codeStages, containersRollout, findWranglerConfigs, npmCiArgs, npmWorkspace, pick, problems, resolvedStack, waves } from "./deploy/stack.mjs"; |
| Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders | 70 | import { withDeployWindow } from "./deploy/status-window.mjs"; |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 71 | |
| Deploying: never roll production back by accident | 72 | const USAGE = "usage: node scripts/deploy.mjs plan|deploy|build|migrate|manifest|doctor|install|build-base|image [--all] [--only a,b] [--skip a,b] [--force] [--rollback] [--concurrency N] [--stage S] [--json]"; |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 73 | |
| 74 | function parseArgs(argv) { | |
| 75 | const opts = { command: argv[0], only: [], skip: [], concurrency: 4, force: false, all: false, json: false }; | |
| 76 | for (let i = 1; i < argv.length; i++) { | |
| 77 | const arg = argv[i]; | |
| 78 | const [flag, inline] = arg.split(/=(.*)/s); | |
| 79 | const value = () => inline ?? argv[++i]; | |
| 80 | if (flag === "--only") opts.only.push(value()); | |
| 81 | else if (flag === "--skip") opts.skip.push(value()); | |
| 82 | else if (flag === "--concurrency") opts.concurrency = Number(value()); | |
| 83 | else if (flag === "--stage") opts.stage = value(); | |
| 84 | else if (flag === "--all") opts.all = true; | |
| 85 | else if (flag === "--force") opts.force = true; | |
| Deploying: never roll production back by accident | 86 | else if (flag === "--rollback") opts.rollback = true; |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 87 | else if (flag === "--json") opts.json = true; |
| 88 | else if (flag === "--check") opts.check = true; | |
| 89 | else if (flag === "--no-migrations") opts.noMigrations = true; | |
| 90 | else if (flag === "--allow-dirty") opts.allowDirty = true; | |
| 91 | else if (flag === "--rebuild-image") opts.rebuildImage = true; | |
| 92 | else if (flag === "--rebuild-base") opts.rebuildBase = true; | |
| 93 | else if (flag === "--no-push") opts.noPush = true; | |
| 94 | else if (flag === "--no-cache") opts.noCache = true; | |
| 95 | else if (flag === "--out") opts.out = value(); | |
| 96 | else if (flag === "--since") opts.since = value(); | |
| 97 | else if (flag === "--github-output") opts.githubOutput = true; | |
| 98 | else throw new Error(`unknown flag ${arg}\n${USAGE}`); | |
| 99 | } | |
| 100 | if (!Number.isInteger(opts.concurrency) || opts.concurrency < 1) throw new Error("--concurrency is a whole number, 1 or more"); | |
| 101 | return opts; | |
| 102 | } | |
| 103 | ||
| 104 | /** The units a command works on: --only (or all), less --skip, in --stage. */ | |
| 105 | function selected(stack, opts) { | |
| 106 | let units = opts.only.length ? pick(stack, opts.only) : [...stack.units]; | |
| 107 | const skipped = pick(stack, opts.skip); | |
| 108 | units = units.filter((u) => !skipped.includes(u)); | |
| 109 | if (opts.stage) { | |
| 110 | if (!codeStages(stack).includes(opts.stage)) throw new Error(`--stage is one of ${codeStages(stack).join(", ")}`); | |
| 111 | units = units.filter((u) => u.stage === opts.stage); | |
| 112 | } | |
| 113 | // Manifest order, whatever order they were named in. | |
| 114 | return stack.units.filter((u) => units.includes(u)); | |
| 115 | } | |
| 116 | ||
| 117 | const log = (...args) => console.error(...args); | |
| 118 | ||
| 119 | /** | |
| 120 | * The plan for a workflow (.g1t/workflows/deploy.yml): job outputs in | |
| 121 | * $GITHUB_OUTPUT, and the plan as a table in $GITHUB_STEP_SUMMARY. | |
| 122 | */ | |
| 123 | function writeGithubOutputs(data, p) { | |
| 124 | const lines = [ | |
| 125 | `commit=${data.commit}`, | |
| 126 | `migrate=${data.migrations.length > 0}`, | |
| 127 | `migrate_units=${data.migrations.map((m) => m.unit).join(",")}`, | |
| 128 | `deploying=${data.units.filter((u) => u.deploy).map((u) => u.unit).join(",")}`, | |
| 129 | ]; | |
| 130 | for (const [stage, { jobs }] of Object.entries(data.stages)) { | |
| 131 | // A matrix needs one entry; an empty stage's job is skipped by its `if`. | |
| 132 | const include = jobs.length ? jobs : [{ group: "none", units: "" }]; | |
| 133 | lines.push(`has_${stage}=${jobs.length > 0}`, `${stage}=${JSON.stringify({ include })}`); | |
| 134 | } | |
| Deploying: a Plan that cannot read migrations says why | 135 | if (data.migration_errors.length) { |
| 136 | // The units, then why for the first: one cause (a token, say) is usually all of them. | |
| 137 | throw new Error(`Could not read pending migrations: ${data.migration_errors.map((e) => e.unit).join(", ")} | |
| 138 | ${data.migration_errors[0].error}`); | |
| 139 | } | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 140 | if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`); |
| 141 | else console.log(lines.join("\n")); | |
| 142 | if (process.env.GITHUB_STEP_SUMMARY) { | |
| 143 | const rows = p.decisions.map((d) => `| ${d.unit.id} | ${d.unit.stage} | ${d.deploy ? "deploy" : ""} | ${d.since ? d.since.slice(0, 12) : "?"} | ${d.reason.replaceAll("|", "\\|")} |`); | |
| 144 | const pending = data.migrations.map((m) => `- ${m.database}: ${m.pending.join(", ")}`); | |
| 145 | appendFileSync( | |
| 146 | process.env.GITHUB_STEP_SUMMARY, | |
| 147 | [`## Deploy plan for ${data.commit.slice(0, 12)}`, "", "| unit | stage | action | live | why |", "| --- | --- | --- | --- | --- |", ...rows, "", pending.length ? "### Pending migrations" : "", ...pending, ""].join("\n"), | |
| 148 | ); | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`; | |
| 153 | ||
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 154 | /** `fn`, called the first time it is wanted and then remembered. */ |
| 155 | const once = (fn) => { | |
| 156 | let answer = null; | |
| 157 | return () => (answer ??= fn()); | |
| 158 | }; | |
| 159 | ||
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 160 | /** Reads what each unit runs and what its database is waiting for. */ |
| 161 | async function survey(units, opts) { | |
| 162 | const live = {}; | |
| 163 | const migrations = {}; | |
| 164 | const tasks = [ | |
| 165 | ...(opts.noLive ? [] : units).map((unit) => async () => { | |
| 166 | live[unit.id] = await readLive(unit); | |
| 167 | }), | |
| 168 | ...(opts.noMigrations ? [] : units.filter((u) => u.d1)).map((unit) => async () => { | |
| 169 | migrations[unit.id] = await pendingMigrations(unit); | |
| 170 | }), | |
| 171 | ]; | |
| Merge a faster deploy plan: Cloudflare's API read directly and in parallel (2 s against 4 min), and the plan runs beside the check | 172 | // Through the API every read starts at once (the requests themselves are |
| 173 | // held to API_CONCURRENCY); through Wrangler, a few processes at a time. | |
| 174 | await pool(tasks, apiAuth() ? tasks.length : Math.max(8, opts.concurrency * 2), (task) => task()); | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 175 | return { live, migrations }; |
| 176 | } | |
| 177 | ||
| 178 | async function plan(stack, opts) { | |
| 179 | const units = selected(stack, opts); | |
| 180 | const head = git.head(); | |
| 181 | const { live, migrations } = await survey(units, opts); | |
| 182 | // --since: what a Worker with no recorded commit is taken to run (once, | |
| 183 | // to adopt Workers deployed before this tool), for example --since HEAD~3. | |
| 184 | if (opts.since) { | |
| 185 | const since = git.resolve(opts.since); | |
| 186 | for (const unit of units) { | |
| 187 | const found = live[unit.id]; | |
| 188 | if (found && !found.sha && !found.missing && !found.error) live[unit.id] = { ...found, sha: since, assumed: true }; | |
| 189 | } | |
| 190 | } | |
| Deploying: never roll production back by accident | 191 | const decisions = decide(units, { live, head, force: opts.force || opts.all, rollback: opts.rollback }); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 192 | return { head, units, live, migrations, decisions }; |
| 193 | } | |
| 194 | ||
| 195 | function buildPlan(stack, opts) { | |
| 196 | const units = selected(stack, opts); | |
| 197 | return { | |
| 198 | head: git.head(), | |
| 199 | units, | |
| 200 | live: {}, | |
| 201 | migrations: {}, | |
| 202 | decisions: units.map((unit) => ({ unit, deploy: true, reason: "build", since: null, files: [], image: false })), | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 206 | function printPlan(stack, { head, decisions, migrations, live }) { | |
| 207 | console.log(`Deploying ${head.slice(0, 12)} (${git.subject()})\n`); | |
| 208 | const rows = decisions.map((d) => [ | |
| 209 | d.unit.id, | |
| 210 | d.unit.stage, | |
| 211 | d.deploy ? "deploy" : "-", | |
| 212 | d.since ? d.since.slice(0, 12) + (live[d.unit.id]?.assumed ? "*" : "") : "?", | |
| 213 | d.unit.d1 ? (migrations[d.unit.id]?.error ? "error" : String(migrations[d.unit.id]?.pending?.length ?? "-")) : "", | |
| 214 | d.reason + (d.image ? "; image rebuilds" : ""), | |
| 215 | ]); | |
| 216 | console.log(table(rows, ["unit", "stage", "action", "live", "migrations", "why"])); | |
| 217 | if (decisions.some((d) => live[d.unit.id]?.assumed)) console.log("* taken from --since: no commit was recorded for it"); | |
| 218 | const pending = Object.entries(migrations).filter(([, m]) => m.pending?.length); | |
| 219 | if (pending.length) { | |
| 220 | console.log("\nPending migrations:"); | |
| 221 | for (const [id, m] of pending) console.log(` ${stack.units.find((u) => u.id === id).d1.database}: ${m.pending.join(", ")}`); | |
| 222 | } | |
| 223 | for (const [id, m] of Object.entries(migrations).filter(([, m]) => m.error)) { | |
| 224 | console.log(`\nCould not list ${id}'s migrations:\n${m.error}`); | |
| 225 | } | |
| 226 | const deploying = decisions.filter((d) => d.deploy).map((d) => d.unit); | |
| 227 | console.log( | |
| 228 | deploying.length | |
| 229 | ? `\n${deploying.length} to deploy: ${byStage(stack, deploying).map((g) => `${g.stage} (${g.units.map((u) => u.id).join(", ")})`).join(" -> ")}` | |
| 230 | : "\nNothing to deploy.", | |
| 231 | ); | |
| 232 | } | |
| 233 | ||
| 234 | /** Output of one unit, prefixed, to the terminal and a log file. */ | |
| 235 | function unitLogger(id) { | |
| 236 | const dir = join(tmpdir(), "g1t-deploy"); | |
| 237 | mkdirSync(dir, { recursive: true }); | |
| 238 | const file = join(dir, `${id}.log`); | |
| 239 | const lines = []; | |
| 240 | return { | |
| 241 | file, | |
| 242 | line: (text) => { | |
| 243 | lines.push(text); | |
| 244 | if (text.trim()) log(`[${id}] ${text}`); | |
| 245 | }, | |
| 246 | save: () => writeFileSync(file, `${lines.join("\n")}\n`), | |
| 247 | }; | |
| 248 | } | |
| 249 | ||
| 250 | /** | |
| 251 | * The runner's image for this checkout, by registry reference: already in | |
| 252 | * the registry (built by an earlier deploy, `deploy.mjs image`, or on | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 253 | * another machine), or made here. Pushing, it is made in the registry |
| 254 | * (scripts/deploy/registry.mjs: the base's layers and one with the | |
| 255 | * binary, nothing pulled), which needs no Docker and no room for the | |
| 256 | * base; if that fails and Docker answers, it is built and pushed with | |
| 257 | * Docker. A dry run builds it with Docker and pushes nothing. `docker` is | |
| 258 | * asked only when needed: asking starts a job's Docker Engine. Returns | |
| 259 | * { ref, note }. | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 260 | */ |
| 261 | async function runnerImage(unit, { dryRun, docker, rebuildImage, rebuildBase }, out) { | |
| 262 | let base = baseState(unit); | |
| 263 | if (!base.current || rebuildBase) { | |
| 264 | if (!rebuildBase) { | |
| 265 | throw new Error( | |
| 266 | `${unit.image.base.context} has changed since ${unit.image.base.lock} was written${base.lock ? "" : " (the base has never been built)"}. Build and push the base, and commit ${unit.image.base.lock}: node scripts/deploy.mjs build-base`, | |
| 267 | ); | |
| 268 | } | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 269 | if (!(await docker())) throw new Error("--rebuild-base needs Docker."); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 270 | await newBase(unit, { push: !dryRun, onLine: out.line }); |
| 271 | base = baseState(unit); | |
| 272 | } | |
| 273 | if (!base.pushed && !dryRun) throw new Error(`${unit.image.base.lock} records a base that was never pushed: node scripts/deploy.mjs build-base`); | |
| 274 | const ref = runnerRef(unit); | |
| 275 | const tag = ref.split(":").pop(); | |
| 276 | if (!rebuildImage) { | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 277 | if (dryRun && (await docker()) && (await dockerHas(ref))) return { ref, note: `image ${tag} already built here` }; |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 278 | if (!dryRun) { |
| 279 | try { | |
| 280 | if (await registryHas(ref)) return { ref, note: `image ${tag} already in the registry` }; | |
| 281 | } catch (error) { | |
| 282 | out.line(`could not ask the registry for ${tag}: ${error.message ?? error}`); | |
| 283 | } | |
| 284 | } | |
| 285 | } | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 286 | if (dryRun && !(await docker())) { |
| 287 | throw new Error(`its image ${tag} is not built here, and Docker does not answer here: a dry run builds it with Docker.`); | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 288 | } |
| 289 | const started = Date.now(); | |
| 290 | const binary = await exec(process.execPath, [join(ROOT, "scripts/build-runner.mjs")], { onLine: out.line }); | |
| 291 | if (binary.code !== 0) throw new Error(`the runner binary did not build:\n${lastLines(binary.out)}`); | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 292 | if (!dryRun) { |
| 293 | try { | |
| 294 | await makeRunnerImage({ ref, base: base.ref, binary: BINARY, onLine: out.line }); | |
| 295 | return { ref, note: `image ${tag} made in the registry in ${seconds(Date.now() - started)}` }; | |
| 296 | } catch (error) { | |
| 297 | if (!(await docker())) { | |
| 298 | throw new Error(`its image ${tag} could not be made in the registry (${error.message ?? error}), and Docker does not answer here to build it instead.`); | |
| 299 | } | |
| 300 | out.line(`could not make ${tag} in the registry (${error.message ?? error}); building it with Docker instead`); | |
| 301 | } | |
| 302 | } | |
| 303 | // With Docker: the base is pulled and unpacked on this disk, so what the | |
| 304 | // disk held is logged before and after, and when it fails. | |
| 305 | await diskReport(out.line, "before the build"); | |
| 306 | try { | |
| 307 | if (!dryRun) await dockerLogin({ push: true }); | |
| 308 | await buildRunnerImage(unit, { ref, base: base.ref, binaryDir: OUT_DIR, onLine: out.line }); | |
| 309 | if (!dryRun) await pushImage(ref, { onLine: out.line }); | |
| 310 | } finally { | |
| 311 | await diskReport(out.line, "after the build"); | |
| 312 | } | |
| 313 | return { ref, note: `image ${tag} ${dryRun ? "built" : "built and pushed"} with Docker in ${seconds(Date.now() - started)}` }; | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 314 | } |
| 315 | ||
| 316 | /** Builds the base (and pushes it unless `push` is false), and writes its lock. */ | |
| 317 | async function newBase(unit, { push = true, noCache = false, onLine = log } = {}) { | |
| 318 | const started = Date.now(); | |
| 319 | const inputs = baseInputs(unit); | |
| 320 | const tag = baseTag(inputs); | |
| 321 | const previous = readBaseLock(unit); | |
| 322 | // Logged in, the base it replaces is pulled as cache. | |
| 323 | if (push || previous?.pushed) { | |
| 324 | try { | |
| 325 | await dockerLogin({ push }); | |
| 326 | } catch (error) { | |
| 327 | if (push) throw error; | |
| 328 | onLine(`not logged in to the registry, so no cache from it: ${error.message ?? error}`); | |
| 329 | } | |
| 330 | } | |
| 331 | const ref = await buildBase(unit, { tag, previous: previous?.pushed ? previous.image : null, onLine, noCache }); | |
| 332 | const built = Date.now(); | |
| 333 | const [size, versions] = await Promise.all([imageSize(ref), baseVersions(ref)]); | |
| 334 | const digest = push ? await pushImage(ref, { onLine }) : null; | |
| 335 | const lock = { image: ref, digest, pushed: push, inputs, built_at: new Date().toISOString(), size_bytes: size, versions }; | |
| 336 | writeBaseLock(unit, lock); | |
| 337 | onLine(`base ${tag}: built in ${seconds(built - started)}${push ? `, pushed in ${seconds(Date.now() - built)}` : ""}, ${(size / 1e9).toFixed(2)} GB unpacked`); | |
| 338 | return lock; | |
| 339 | } | |
| 340 | ||
| 341 | /** Builds (and unless `dryRun`, deploys) one unit. */ | |
| 342 | async function ship(unit, decision, { head, subject, dirty, dryRun, docker, rebuildImage, rebuildBase }) { | |
| 343 | const started = Date.now(); | |
| 344 | const out = unitLogger(unit.id); | |
| 345 | const cwd = join(ROOT, unit.path); | |
| 346 | const result = { unit: unit.id, stage: unit.stage, ok: false, version: null, ms: 0, note: "" }; | |
| 347 | try { | |
| 348 | if (unit.kind === "react-router" || unit.kind === "astro") { | |
| 349 | // One command string: Node warns about arguments passed beside shell: true. | |
| 350 | const built = await exec("npm run build", [], { cwd, onLine: out.line, shell: true, env: wranglerEnv() }); | |
| 351 | if (built.code !== 0) throw new Error(`npm run build failed:\n${lastLines(built.out)}`); | |
| 352 | } | |
| 353 | const args = ["deploy"]; | |
| 354 | if (dryRun) { | |
| 355 | args.push("--dry-run", "--outdir", join(tmpdir(), "g1t-deploy", "dist", unit.id)); | |
| 356 | } else { | |
| 357 | const { message, tag } = annotation(head, subject); | |
| 358 | args.push("--message", dirty ? message.replace(/^g1t-deploy/, "g1t-deploy-dirty") : message, "--tag", tag); | |
| 359 | } | |
| 360 | if (unit.image) { | |
| 361 | // Wrangler is given the image by reference, so it builds nothing. | |
| 362 | const image = await runnerImage(unit, { dryRun, docker, rebuildImage, rebuildBase }, out); | |
| 363 | args.push("--config", writeDeployConfig(unit, image.ref)); | |
| A deploy that changes the runner's image or its container settings rolls every idle sandbox over at once (--containers-rollout=immediate), where Cloudflare's default rollout went in steps over wall-clock time and, with deploys often half an hour apart, never reached every sandbox: jobs kept starting on old images and old machine sizes. A change to the container settings alone, such as the instance type, now rolls out too; before, an unchanged image meant --containers-rollout none and the new settings were never applied. Busy sandboxes still get rollout_active_grace_period to finish. | 364 | args.push("--containers-rollout", containersRollout(unit, decision, { rebuildImage, rebuildBase })); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 365 | result.note = image.note; |
| 366 | } | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 367 | // Tried again after a wait when Cloudflare, not what is deployed, is |
| 368 | // why it failed: deploying the same version again is harmless. | |
| 369 | let deployed = await wrangler(args, { cwd, onLine: out.line }); | |
| 370 | for (const wait of DEPLOY_RETRY_WAITS_MS) { | |
| 371 | if (deployed.code === 0 || dryRun || !retryableDeploy(deployed.out)) break; | |
| 372 | out.line(`wrangler deploy failed; trying again in ${wait / 1000}s:\n${wranglerError(deployed.out)}`); | |
| 373 | await new Promise((resolve) => setTimeout(resolve, wait)); | |
| 374 | deployed = await wrangler(args, { cwd, onLine: out.line }); | |
| 375 | } | |
| 376 | if (deployed.code !== 0) throw new Error(`wrangler deploy failed:\n${wranglerError(deployed.out)}`); | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 377 | result.version = dryRun ? "(dry run)" : versionFrom(deployed.out); |
| 378 | result.ok = true; | |
| A deploy of a unit with containers says, for each of its container applications, what Cloudflare runs after it: the instance type, vCPU, memory and disk, the version, how many instances, and whether a rollout is still going, from wrangler containers list. A deploy log then shows whether new container settings took; reading them never fails the deploy. | 379 | if (unit.image && !dryRun) await reportContainers(unit, cwd, out); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 380 | } catch (error) { |
| 381 | result.note = String(error.message ?? error); | |
| 382 | } finally { | |
| 383 | if (unit.image) removeDeployConfig(unit); | |
| 384 | } | |
| 385 | result.ms = Date.now() - started; | |
| 386 | out.save(); | |
| 387 | if (!result.ok) result.note += `\n full log: ${out.file}`; | |
| 388 | return result; | |
| 389 | } | |
| 390 | ||
| A deploy of a unit with containers says, for each of its container applications, what Cloudflare runs after it: the instance type, vCPU, memory and disk, the version, how many instances, and whether a rollout is still going, from wrangler containers list. A deploy log then shows whether new container settings took; reading them never fails the deploy. | 391 | /** |
| 392 | * What Cloudflare runs for a unit's container applications after a deploy: | |
| 393 | * each one's instance type, size, version and rollout, from `wrangler | |
| 394 | * containers list`, so a deploy log says whether new settings took. Never | |
| 395 | * fails the deploy. | |
| 396 | */ | |
| 397 | async function reportContainers(unit, cwd, out) { | |
| 398 | try { | |
| 399 | const listed = await wrangler(["containers", "list"], { cwd }); | |
| 400 | const text = listed.out.slice(listed.out.indexOf("[")); | |
| 401 | const apps = JSON.parse(text.slice(0, text.lastIndexOf("]") + 1)); | |
| 402 | for (const app of apps.filter((a) => String(a.name ?? "").startsWith(`${unit.worker}-`))) { | |
| 403 | const c = app.configuration ?? {}; | |
| 404 | const disk = c.disk?.size_mb ?? c.disk_mb ?? c.disk?.size; | |
| 405 | const size = [c.instance_type, c.vcpu != null && `${c.vcpu} vCPU`, c.memory_mib != null && `${c.memory_mib} MiB`, disk != null && `${disk} MB disk`].filter(Boolean).join(", "); | |
| 406 | const rollout = app.active_rollout_id ? `rollout ${app.active_rollout_id} in progress` : "no rollout in progress"; | |
| 407 | out.line(`container ${app.name}: ${size || JSON.stringify(c).slice(0, 200)}; version ${app.version ?? "?"}; ${app.instances ?? "?"} instances; ${rollout}`); | |
| 408 | } | |
| 409 | } catch (error) { | |
| When wrangler containers list does not answer in JSON, the deploy log shows what it said instead. | 410 | out.line(`could not read the container applications as JSON (${error.message ?? error}); wrangler said:`); |
| 411 | const said = await wrangler(["containers", "list"], { cwd }).catch(() => ({ out: "" })); | |
| 412 | for (const line of said.out.split(" | |
| 413 | ").filter((l) => l.trim()).slice(0, 40)) out.line(` ${line}`); | |
| A deploy of a unit with containers says, for each of its container applications, what Cloudflare runs after it: the instance type, vCPU, memory and disk, the version, how many instances, and whether a rollout is still going, from wrangler containers list. A deploy log then shows whether new container settings took; reading them never fails the deploy. | 414 | } |
| 415 | } | |
| 416 | ||
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 417 | async function migrate(stack, units, migrations, opts) { |
| 418 | const due = units.filter((u) => migrations[u.id]?.pending?.length); | |
| 419 | const broken = units.filter((u) => migrations[u.id]?.error); | |
| 420 | if (broken.length) { | |
| 421 | throw new Error(`Could not read pending migrations for ${broken.map((u) => u.id).join(", ")}; nothing was deployed.`); | |
| 422 | } | |
| 423 | if (!due.length) return []; | |
| 424 | log(`== migrations: ${due.map((u) => `${u.d1.database} (${migrations[u.id].pending.length})`).join(", ")}`); | |
| 425 | const results = await pool(due, opts.concurrency, async (unit) => { | |
| 426 | const started = Date.now(); | |
| 427 | const out = unitLogger(`${unit.id}-migrations`); | |
| 428 | const applied = await applyMigrations(unit, out.line); | |
| 429 | out.save(); | |
| 430 | return { | |
| 431 | unit: `${unit.id} (D1 ${unit.d1.database})`, | |
| 432 | stage: "migrations", | |
| 433 | ok: applied.code === 0, | |
| 434 | version: `${migrations[unit.id].pending.length} applied`, | |
| 435 | ms: Date.now() - started, | |
| 436 | note: applied.code === 0 ? "" : `${lastLines(applied.out)}\n full log: ${out.file}`, | |
| 437 | }; | |
| 438 | }); | |
| 439 | return results; | |
| 440 | } | |
| 441 | ||
| 442 | function summary(results) { | |
| 443 | const rows = results.map((r) => [r.unit, r.stage, r.ok ? "ok" : r.skipped ? "not started" : "FAILED", r.version ?? "", r.ms ? seconds(r.ms) : "", r.note.split("\n")[0]]); | |
| 444 | console.log(`\n${table(rows, ["unit", "stage", "result", "version", "time", "note"])}`); | |
| 445 | for (const r of results.filter((r) => !r.ok && !r.skipped)) console.log(`\n${r.unit}: ${r.note}`); | |
| 446 | } | |
| 447 | ||
| 448 | async function deploy(stack, opts, { dryRun = false } = {}) { | |
| 449 | const started = Date.now(); | |
| 450 | // A build reads nothing from Cloudflare: it builds what it is given. | |
| 451 | const p = dryRun ? buildPlan(stack, opts) : await plan(stack, opts); | |
| 452 | if (!dryRun) printPlan(stack, p); | |
| 453 | const deploying = p.decisions.filter((d) => d.deploy); | |
| 454 | const touched = deploying.map((d) => d.unit); | |
| 455 | const head = p.head; | |
| 456 | ||
| 457 | // A deploy names the commit it came from, so a dirty tree would be | |
| 458 | // recorded as something it is not. | |
| 459 | const dirtyFiles = git.dirty(); | |
| 460 | const dirty = deploying.some((d) => dirtyFiles.some((file) => file.startsWith(`${d.unit.path}/`) || d.unit.dependsOn.some((dir) => file.startsWith(`${dir}/`)) || d.unit.inputs.includes(file))); | |
| 461 | if (dirty && !dryRun && !opts.allowDirty) { | |
| 462 | throw new Error("Uncommitted changes touch what would deploy. Commit them, or pass --allow-dirty (the deploy then records no commit, and the next plan deploys it again)."); | |
| 463 | } | |
| 464 | ||
| 465 | const results = []; | |
| 466 | if (!dryRun && !opts.noMigrations) { | |
| 467 | const migrated = await migrate(stack, p.units, p.migrations, opts); | |
| 468 | results.push(...migrated); | |
| 469 | if (migrated.some((r) => !r.ok)) { | |
| 470 | summary(results); | |
| 471 | return false; | |
| 472 | } | |
| 473 | } | |
| 474 | if (!touched.length) { | |
| 475 | if (results.length) summary(results); | |
| 476 | return true; | |
| 477 | } | |
| 478 | ||
| 479 | if (touched.some((u) => u.kind === "rust-worker")) ensureWorkerBuild(); | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 480 | const docker = once(dockerAvailable); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 481 | const context = { head, subject: git.subject(), dirty, dryRun, docker, rebuildImage: opts.rebuildImage, rebuildBase: opts.rebuildBase }; |
| Deploying: a deploy by hand shows on the repository's Deployments page | 482 | // A deploy run by hand shows on the repository's Deployments page; a |
| 483 | // dirty tree is not a commit anyone can look at, so it is left out. | |
| 484 | const settle = dryRun || dirty ? null : await reportDeployment({ head, subject: context.subject, units: touched.map((u) => u.id), log }); | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 485 | |
| Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders | 486 | // status.g1t.sh hears the deploy start and finish (STATUS_DEPLOY_TOKEN; |
| 487 | // nothing without it), so the restarts it causes are not drafted as | |
| 488 | // incidents. Never in a dry run, and it never fails a deploy. | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 489 | let failed = false; |
| Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders | 490 | await withDeployWindow( |
| 491 | async () => { | |
| 492 | for (const { stage, units } of byStage(stack, touched)) { | |
| 493 | if (failed) { | |
| 494 | for (const unit of units) results.push({ unit: unit.id, stage, ok: false, skipped: true, ms: 0, note: "an earlier stage failed" }); | |
| 495 | continue; | |
| 496 | } | |
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 497 | // A Worker that does not exist yet goes before those bound to it. |
| 498 | for (const wave of waves(units, (unit) => Boolean(deploying.find((d) => d.unit === unit)?.missing))) { | |
| 499 | if (failed) { | |
| 500 | for (const unit of wave) results.push({ unit: unit.id, stage, ok: false, skipped: true, ms: 0, note: "a Worker it binds to failed to deploy" }); | |
| 501 | continue; | |
| 502 | } | |
| 503 | log(`== ${stage}: ${wave.map((u) => u.id).join(", ")}`); | |
| 504 | const shipped = await pool(wave, opts.concurrency, (unit) => ship(unit, deploying.find((d) => d.unit === unit), context)); | |
| 505 | results.push(...shipped); | |
| 506 | failed = shipped.some((r) => !r.ok); | |
| 507 | } | |
| Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders | 508 | } |
| 509 | }, | |
| 510 | { id: head ?? null, dryRun, log }, | |
| 511 | ); | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 512 | summary(results); |
| Deploying: a deploy by hand shows on the repository's Deployments page | 513 | await settle?.(!failed); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 514 | console.log(`\n${failed ? "Failed" : dryRun ? "Built" : "Deployed"} in ${seconds(Date.now() - started)}.`); |
| 515 | return !failed; | |
| 516 | } | |
| 517 | ||
| 518 | async function doctor(stack, opts) { | |
| 519 | const units = selected(stack, opts); | |
| 520 | const rows = await pool(units, 8, async (unit) => { | |
| 521 | if (!unit.secrets.length) return [unit.id, "", "", ""]; | |
| 522 | const found = await wrangler(["secret", "list", "--name", unit.worker, "--format", "json"], { cwd: join(ROOT, unit.path) }); | |
| 523 | if (found.code !== 0) return [unit.id, unit.secrets.join(" "), "?", "could not read"]; | |
| 524 | const have = new Set(jsonFrom(found.out).map((s) => s.name)); | |
| 525 | const missing = unit.secrets.filter((name) => !have.has(name)); | |
| 526 | return [unit.id, unit.secrets.join(" "), missing.join(" "), missing.length ? "missing" : "ok"]; | |
| 527 | }); | |
| 528 | console.log(table(rows, ["unit", "secrets", "missing", "result"])); | |
| 529 | return rows.every((r) => r[3] !== "missing"); | |
| 530 | } | |
| 531 | ||
| 532 | async function install(stack, opts) { | |
| 533 | const units = selected(stack, opts); | |
| 534 | const args = npmCiArgs(units, npmWorkspace()); | |
| 535 | log(`npm ${args.join(" ")}`); | |
| Every install in g1t's workflows and deploy tool goes through scripts/deploy/npm-ci.mjs, which runs npm ci again when a connection drops: npm does not retry a body that breaks off midway, so an ECONNRESET or network aborted, an install that ends without saying why, or one that prints nothing for 12 minutes is tried again, up to three times; a real error, such as a lockfile out of step, still fails at once. A test keeps every workflow on it. | 536 | // Through npm-ci.mjs, which runs it again when a connection drops. |
| 537 | const done = await exec(`node scripts/deploy/npm-ci.mjs ${args.slice(1).join(" ")}`, [], { cwd: ROOT, shell: true, onLine: (line) => log(line) }); | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 538 | return done.code === 0; |
| 539 | } | |
| 540 | ||
| 541 | function manifest(stack, opts) { | |
| 542 | const found = problems(stack, findWranglerConfigs()); | |
| 543 | if (opts.check) { | |
| 544 | for (const problem of found) console.log(`- ${problem}`); | |
| 545 | console.log(found.length ? `\n${found.length} problems in deploy/stack.jsonc.` : "deploy/stack.jsonc is consistent with every wrangler.jsonc."); | |
| 546 | return !found.length; | |
| 547 | } | |
| 548 | const out = stack.units.map(({ config, ...unit }) => ({ | |
| 549 | ...unit, | |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 550 | queues: { |
| 551 | produces: (config?.queues?.producers ?? []).map((q) => q.queue), | |
| 552 | consumes: (config?.queues?.consumers ?? []).map((q) => q.queue), | |
| 553 | dead_letter: [...new Set((config?.queues?.consumers ?? []).map((q) => q.dead_letter_queue).filter(Boolean))], | |
| 554 | }, | |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 555 | kv: (config?.kv_namespaces ?? []).map((kv) => stack.resources.kv?.[kv.id] ?? kv.id), |
| 556 | r2: (config?.r2_buckets ?? []).map((b) => b.bucket_name), | |
| 557 | vectorize: (config?.vectorize ?? []).map((v) => v.index_name), | |
| 558 | dispatch_namespaces: (config?.dispatch_namespaces ?? []).map((d) => d.namespace), | |
| 559 | routes: (config?.routes ?? []).map((r) => r.pattern), | |
| 560 | })); | |
| 561 | if (opts.json) console.log(JSON.stringify({ stages: stack.stages, units: out }, null, 2)); | |
| 562 | else | |
| 563 | console.log( | |
| 564 | table( | |
| 565 | out.map((u) => [u.id, u.stage, u.kind, u.worker, u.d1?.database ?? "", u.dependsOn.join(" ")]), | |
| 566 | ["unit", "stage", "kind", "worker", "d1", "built from (besides its folder)"], | |
| 567 | ), | |
| 568 | ); | |
| 569 | return true; | |
| 570 | } | |
| 571 | ||
| 572 | /** The unit with a Containers image (the runner), or the one named. */ | |
| 573 | function imageUnit(stack, opts) { | |
| 574 | const units = (opts.only.length ? pick(stack, opts.only) : stack.units).filter((u) => u.image?.base); | |
| 575 | if (units.length !== 1) throw new Error("Name the unit with a Containers image: --only runner"); | |
| 576 | return units[0]; | |
| 577 | } | |
| 578 | ||
| 579 | async function main() { | |
| 580 | const opts = parseArgs(process.argv.slice(2)); | |
| 581 | const stack = resolvedStack(); | |
| 582 | switch (opts.command) { | |
| 583 | case "plan": { | |
| 584 | const p = await plan(stack, opts); | |
| 585 | const data = planJson(stack, p.decisions, p.migrations, p.head); | |
| 586 | if (opts.out) writeFileSync(opts.out, `${JSON.stringify(data)}\n`); | |
| 587 | if (opts.githubOutput) writeGithubOutputs(data, p); | |
| 588 | if (opts.json) console.log(JSON.stringify(data, null, 2)); | |
| 589 | else if (!opts.githubOutput || process.env.GITHUB_OUTPUT) printPlan(stack, p); | |
| 590 | return true; | |
| 591 | } | |
| 592 | case "deploy": | |
| 593 | return deploy(stack, opts); | |
| 594 | case "build": | |
| 595 | return deploy(stack, { ...opts, force: true }, { dryRun: true }); | |
| 596 | case "migrate": { | |
| 597 | const units = selected(stack, opts); | |
| 598 | const { migrations } = await survey(units.filter((u) => u.d1), { ...opts, noMigrations: false, noLive: true }); | |
| 599 | const results = await migrate(stack, units, migrations, opts); | |
| 600 | if (results.length) summary(results); | |
| 601 | else console.log("No migrations to apply."); | |
| 602 | return results.every((r) => r.ok); | |
| 603 | } | |
| 604 | case "manifest": | |
| 605 | return manifest(stack, opts); | |
| 606 | case "doctor": | |
| 607 | return doctor(stack, opts); | |
| 608 | case "install": | |
| 609 | return install(stack, opts); | |
| 610 | case "build-base": { | |
| 611 | const unit = imageUnit(stack, opts); | |
| 612 | if (!(await dockerAvailable())) throw new Error("build-base needs Docker."); | |
| 613 | const lock = await newBase(unit, { push: !opts.noPush, noCache: opts.noCache }); | |
| 614 | console.log(JSON.stringify(lock, null, 2)); | |
| 615 | if (lock.pushed) console.log(`\nCommit ${unit.image.base.lock}: the next deploy builds the runner's image on this base.`); | |
| 616 | return true; | |
| 617 | } | |
| 618 | case "image": { | |
| 619 | const unit = imageUnit(stack, opts); | |
| 620 | const out = unitLogger(`${unit.id}-image`); | |
| A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile. | 621 | const docker = once(dockerAvailable); |
| Billing's usage total is labeled at price, and Usage says what part of it was paid for | 622 | const image = await runnerImage(unit, { dryRun: Boolean(opts.noPush), docker, rebuildImage: opts.rebuildImage, rebuildBase: opts.rebuildBase }, out); |
| 623 | out.save(); | |
| 624 | console.log(`${image.ref}\n${image.note}`); | |
| 625 | return true; | |
| 626 | } | |
| 627 | default: | |
| 628 | console.error(USAGE); | |
| 629 | return false; | |
| 630 | } | |
| 631 | } | |
| 632 | ||
| 633 | main().then( | |
| 634 | (ok) => process.exit(ok ? 0 : 1), | |
| 635 | (error) => { | |
| 636 | console.error(`\n${error.message ?? error}`); | |
| 637 | process.exit(1); | |
| 638 | }, | |
| 639 | ); |
This file's history is long; its oldest lines are credited to the oldest commit read.