diff --git a/apps/cli/src/legacy/cli/legacy-boolean-flag-defaults.unit.test.ts b/apps/cli/src/legacy/cli/legacy-boolean-flag-defaults.unit.test.ts index b4a032b883..2d40faf7a0 100644 --- a/apps/cli/src/legacy/cli/legacy-boolean-flag-defaults.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-boolean-flag-defaults.unit.test.ts @@ -20,6 +20,10 @@ import { legacyRoot } from "./root.ts"; * `boolean`. The flag only misbehaves when a real invocation omits it, which is * precisely the invocation no handler test makes — so the guard walks the * command tree instead of waiting for a command to be exercised end to end. + * + * `experimental workers push --no-wait` is the flag that prompted it: it first + * shipped with neither closer, which made a plain + * `supabase experimental workers push` fail to parse at all. */ /** diff --git a/apps/cli/src/legacy/commands/experimental/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/experimental/workers/push/SIDE_EFFECTS.md index 30c0305291..a7437d1e42 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/experimental/workers/push/SIDE_EFFECTS.md @@ -32,20 +32,24 @@ | `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region | -`GET` is polled until `build_state` leaves `building`. +`GET /v2/projects/{ref}/workers/{name}` is polled until `build_state` leaves +`building`. It is skipped entirely in two cases: under `--no-wait`, and when +the deploy response already carried a terminal `build_state`. Either way the +run reports the accepted spec the deploy response returned. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------- | -| `0` | success | -| `1` | no workers named and none found in the project | -| `1` | a worker's source is missing, not a directory, or empty | -| `1` | a worker's source directory cannot be read | -| `1` | a worker's source links to a path outside itself | -| `1` | build context upload failed | -| `1` | the build reached `failed`, or never left `building` | -| `1` | API error, or project not enrolled in the alpha | +| Code | Condition | +| ---- | -------------------------------------------------------------------- | +| `0` | success | +| `1` | no workers named and none found in the project | +| `1` | a worker's source is missing, not a directory, or empty | +| `1` | a worker's source directory cannot be read | +| `1` | a worker's source links to a path outside itself | +| `1` | build context upload failed | +| `1` | the build reached `failed`, or never left `building` | +| `1` | with `--no-wait`: the deploy was answered with `build_state: failed` | +| `1` | API error, or project not enrolled in the alpha | ## Environment Variables @@ -72,16 +76,28 @@ payload always carries a `workers` array, which a flat `KEY=value` list cannot express, and discovering that at the end would fail the command with the remote project already changed. -A multi-worker run stops at the first failure, and names the workers it never -attempted on stderr in **every** format, machine ones included: that run is a -CI run, where nobody watched the loop and "what still needs deploying" is the -question the failure raises. The per-worker `Deploying Worker n/N:` announcement -is text-only by contrast, since it is progress rather than an outcome. - -Both retry suggestions — the one on a failed build and the one on a build that -never settled — carry an explicit `--project-ref` when the flag supplied the -ref, since they are copy-pasted verbatim. A suggestion that dropped it would -re-resolve against whatever this checkout happens to be linked to. +Under `--no-wait` the deploy returns with the build still running, so the +follow-up hint (`workers status`) is emitted as a success trailer: stderr, +once, at the end of the run rather than between workers. **Text output +only** — like the rest of the human deploy report it sits behind +`output.format === "text"` and the `-o` check, so `--output-format json`, +`stream-json` and every legacy `-o` mode emit no hint. Machine callers read +`build_state` from the payload instead. The hint carries an explicit +`--project-ref` when the flag supplied one, since it is copy-pasted verbatim. + +A multi-worker run stops at the first failure, and names on stderr in **every** +format, machine ones included, both the workers it never attempted and — under +`--no-wait` — the accepted workers whose builds it left running. Neither report +has the trailer's format guard: that run is a CI run, where nobody watched the +loop, and "what still needs deploying" and "what is still in flight" are both +part of the question the failure raises. The second report also covers a real +gap, since `runCli` drains success trailers only on exit code 0, so a failing +run discards every follow-up hint it had queued. + +Under `--no-wait` the `Image` row and the payload's `image_version` are omitted +while `build_state` is `building`. The deploy response may carry an +`image_version` — a re-push of a worker that is already serving echoes the image +it is serving now — and that is the previous build's, not this one's. The presigned `PUT` above is the one request whose URL is itself a credential. `--debug` logs every request URL, so `legacyHttpClientLayer` redacts query diff --git a/apps/cli/src/legacy/commands/experimental/workers/push/push.command.ts b/apps/cli/src/legacy/commands/experimental/workers/push/push.command.ts index 5bbc32edae..73be5300f1 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/push/push.command.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/push/push.command.ts @@ -24,6 +24,18 @@ const config = { ), Flag.optional, ), + noWait: Flag.boolean("no-wait").pipe( + // The deploy POST is answered once the platform has accepted the spec and + // the uploaded context, and the server-side container build that follows + // routinely runs for minutes. Waiting stays the default so a plain push + // still reports the build's verdict, and `--no-wait` is the opt-out for the + // callers — an inner-loop redeploy, a fire-and-forget CI step — that only + // need the deploy accepted. + Flag.withDescription( + "Return once the deploy is accepted, without waiting for the server-side build to finish.", + ), + Flag.withDefault(false), + ), projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, @@ -51,6 +63,10 @@ export const legacyWorkersPushCommand = Command.make("push", config).pipe( command: "supabase experimental workers push api web", description: "Deploy several workers by name", }, + { + command: "supabase experimental workers push api --no-wait", + description: "Deploy without blocking on the build", + }, ]), Command.withHandler((flags) => legacyWorkersPush(flags).pipe( diff --git a/apps/cli/src/legacy/commands/experimental/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/experimental/workers/push/push.handler.ts index cc90677741..9b9936cd83 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/push/push.handler.ts @@ -1,6 +1,7 @@ import { Effect, FileSystem, Option, Predicate, type Schedule } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../../../../shared/output/output.service.ts"; +import { emitSuccessTrailer } from "../../../../../shared/cli/success-trailer.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; import { legacyEmitWorkersMachineOutput, @@ -70,6 +71,13 @@ import type { LegacyWorkersPushFlags } from "./push.command.ts"; * code takes the same path, with the base image and a copy synthesized in place * of your Dockerfile. Every runtime this CLI offers has code to package, so * there is no path here that skips the upload. + * + * The command waits for that server-side build by default, so a plain push + * reports the build's verdict rather than only that the deploy was accepted. + * The build routinely runs for minutes, though, which makes every successful + * deploy as slow as the slowest one — so `--no-wait` returns as soon as the + * platform accepts the deploy, for an inner-loop redeploy or a CI step that + * only needs the spec on file. */ const resolveRuntime = Effect.fnUntraced(function* (options: { @@ -186,6 +194,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { */ readonly refSuffix: string; readonly instances: Option.Option; + /** `--no-wait`: return once the deploy is accepted instead of blocking on the build. */ + readonly noWait: boolean; readonly pollSchedule?: Schedule.Schedule; readonly pollRetrySchedule?: Schedule.Schedule; /** Suppresses this step's human output when `-o` owns stdout. */ @@ -325,18 +335,38 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { }; const deploying = yield* output.task("Deploying worker..."); - yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe( + // The response to the deploy itself is the last thing this command can learn + // without waiting: the platform answers it only after accepting the spec and + // the uploaded context, and it carries the accepted spec back. Everything + // after this point is the server-side container build. + const accepted = yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe( Effect.tapError(() => deploying.fail()), ); - const settled = yield* awaitWorkerBuild(api, projectRef, name, { - schedule: input.pollSchedule, - retrySchedule: input.pollRetrySchedule, - refSuffix: input.refSuffix, - onPoll: (polled) => - polled.buildState === "building" ? deploying.message("Building worker...") : Effect.void, - }).pipe(Effect.tapError(() => deploying.fail())); - + // Polled only when the deploy response left the build unresolved. + // `V2DeployAWorkerOutput` permits a terminal `active` or `failed` on the + // deploy itself, and that verdict is this deploy's — a fresh `GET` can only + // contradict it: `awaitWorkerBuild` reads a post-deploy 404 as "still + // building", so an already-`failed` deploy could burn the whole poll budget + // and surface as a timeout, and a concurrent deployment could answer with a + // state that belongs to someone else's build. + const settled = + input.noWait || accepted.buildState !== "building" + ? accepted + : yield* awaitWorkerBuild(api, projectRef, name, { + schedule: input.pollSchedule, + retrySchedule: input.pollRetrySchedule, + refSuffix: input.refSuffix, + onPoll: (polled) => + polled.buildState === "building" + ? deploying.message("Building worker...") + : Effect.void, + }).pipe(Effect.tapError(() => deploying.fail())); + + // Checked whether or not the build was waited on: the verdict can arrive on + // the deploy response as readily as on a poll. A spec already in `failed` is + // a refusal the command should report as one, rather than exiting zero on a + // worker that will never come up. if (settled.buildState === "failed") { yield* deploying.clear(); return yield* Effect.fail( @@ -356,6 +386,16 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { ? workerUrl(projectRef, settings.projectHost, name) : undefined; + // Dropped while the build is still running, rather than passed through. + // `image_version` is optional-but-permitted on the deploy response, so a + // re-push of a worker that is already serving can echo the image it is + // serving *now* — the previous build's, not this one's. Rendered beside + // `State building` that names an image this deploy did not produce, and a + // script reading `image_version` next to `build_state: "building"` would take + // it for the new one. Only reachable under `--no-wait`; the default polls + // until the build leaves `building`, so `settled` carries the real image. + const imageVersion = settled.buildState === "building" ? undefined : settled.imageVersion; + // Suppressed when `-o` is in play: the payload owns stdout, and these lines // would land in the middle of it. if (output.format === "text" && !input.machineOutput) { @@ -367,13 +407,42 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { ); yield* output.raw( legacyRenderWorkerDetails([ + // Labelled `State`, and placed first, the way `workers status` renders + // the same field: under `--no-wait` it is the one row that says the + // worker is not serving yet, so it should not be hunted for at the + // bottom of the block. + ["State", settled.buildState], ["Runtime", runtime], ["Size", formatApiSize(settled.spec.size)], - ["Image", settled.imageVersion ?? ""], + // Empty under `--no-wait`: this deploy's image does not exist until the + // build produces one, and `legacyRenderWorkerDetails` drops an + // empty-valued row. + ["Image", imageVersion ?? ""], ["Access", settled.spec.exposure], ["URL", url ?? ""], ]), ); + if (settled.buildState === "building") { + // A success trailer rather than an inline stderr line: this is a "what to + // run next" hint, which `stop`, `bootstrap`, `migration repair` and + // `gen signing-key` all route through `emitSuccessTrailer` so it prints + // once at the end of the run instead of scrolling away. It matters here + // more than for those: pushing several workers would otherwise bury each + // worker's hint under the next worker's packaging and deploy output. + // + // One short sentence per line, with the command aqua'd the way every + // other follow-up hint in this shell writes them. The single wrapped + // paragraph this replaced re-flowed differently at every terminal width + // and buried the command mid-sentence. + // + // No "drop `--no-wait` next time" line to go with it: reaching here means + // the caller asked not to wait, so the only thing left to tell them is + // where the build's verdict will show up. + yield* emitSuccessTrailer( + `\nYour build was submitted successfully.\n` + + `Run ${legacyAqua(`supabase experimental workers status ${name}${input.refSuffix}`)} to check on it.\n`, + ); + } } return { @@ -385,7 +454,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { // Omitted rather than present-and-undefined: `-o toml` hands the payload to // smol-toml, which cannot represent undefined and would throw *after* the // upload and deploy had completed. Same reason `url` is spread below. - ...(settled.imageVersion === undefined ? {} : { image_version: settled.imageVersion }), + ...(imageVersion === undefined ? {} : { image_version: imageVersion }), build_state: settled.buildState, ...(url === undefined ? {} : { url }), }; @@ -413,6 +482,31 @@ const reportUnattempted = Effect.fnUntraced(function* (skipped: ReadonlyArray) { + if (building.length === 0) { + return; + } + const output = yield* Output; + yield* output.raw(`Still building: ${building.join(", ")}\n`, "stderr"); +}); + /** * `supabase experimental workers push [name...]` — deploy the named workers, or every worker * in the project when none are named, mirroring `supabase functions deploy`. @@ -422,6 +516,10 @@ const reportUnattempted = Effect.fnUntraced(function* (skipped: ReadonlyArray> = []; + // Accepted, but not finished: their builds outlive a failure further down + // the loop, so the failure path has to name them. See `reportStillBuilding`. + const stillBuilding: Array = []; for (const [index, name] of names.entries()) { if (names.length > 1 && !machineOutput && output.format === "text") { // stderr, unblanked and labelled, the way `functions deploy` announces @@ -492,20 +593,31 @@ export const legacyWorkersPush = Effect.fn("legacy.experimental.workers.push")(f "stderr", ); } - deployed.push( - yield* deployOneWorker({ - project, - name, - projectRef, - refSuffix, - instances: flags.instances, - machineOutput, - ...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }), - ...(options.pollRetrySchedule === undefined - ? {} - : { pollRetrySchedule: options.pollRetrySchedule }), - }).pipe(Effect.tapError(() => reportUnattempted(names.slice(index + 1)))), + const worker = yield* deployOneWorker({ + project, + name, + projectRef, + refSuffix, + instances: flags.instances, + noWait: flags.noWait, + machineOutput, + ...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }), + ...(options.pollRetrySchedule === undefined + ? {} + : { pollRetrySchedule: options.pollRetrySchedule }), + }).pipe( + // In flight before what never started: one is a thing the user now has + // to follow, the other a thing they have to re-run. + Effect.tapError(() => + reportStillBuilding(stillBuilding).pipe( + Effect.andThen(reportUnattempted(names.slice(index + 1))), + ), + ), ); + deployed.push(worker); + if (worker.build_state === "building") { + stillBuilding.push(name); + } } // Only for a run that deployed several: one worker already said so itself, diff --git a/apps/cli/src/legacy/commands/experimental/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/experimental/workers/push/push.integration.test.ts index 0123082b4d..1f219a80d5 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/push/push.integration.test.ts @@ -45,6 +45,9 @@ function flags(overrides: Partial = {}): LegacyWorkersPu return { names: ["api"], instances: Option.none(), + // Mirrors the command default: a push waits for the build, and only the + // scenarios that are about the early return opt out of it. + noWait: false, projectRef: Option.none(), ...overrides, }; @@ -157,9 +160,81 @@ describe("legacy workers push", () => { expect(out.stdoutText).toContain("Runtime"); expect(out.stdoutText).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); expect(out.stdoutText).toContain("v1"); + // The build settled, so there is nothing left to follow up on. + expect(out.stderrText).not.toContain("supabase experimental workers status api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + it.live("returns once the deploy is accepted when --no-wait is passed", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ noWait: true }); + + expect(http.routeKeys).toEqual([ + `POST ${workersRoute("/api/uploads")}`, + "PUT /deploy-context/api.tar.gz", + `POST ${workersRoute("/api/deploy")}`, + ]); + + expect(out.stdoutText).toContain("Deployed Worker api"); + // No image exists yet, so the row is dropped rather than rendered empty. + expect(out.stdoutText).not.toContain("Image"); + expect(out.stderrText).toContain("supabase experimental workers status api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `V2DeployAWorkerOutput` permits a terminal state on the deploy response + // itself, and that verdict is this deploy's. A poll on top of it can only + // contradict it — `awaitWorkerBuild` reads a post-deploy 404 as "still + // building", so an already-settled deploy would burn the poll budget and + // surface as a timeout instead of the answer the platform already gave. + describe("honours a terminal deploy response instead of polling", () => { + const settledOnDeploy = (repoDir: string, state: "active" | "failed") => + setupLegacyWorkers({ + workdir: repoDir, + routes: routes({ + [`POST ${workersRoute("/api/deploy")}`]: { + status: 202, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: state, + ...(state === "active" ? { imageVersion: "v1" } : {}), + }), + }, + }, + }), + }); + + it.live("reports a deploy that came back already active", () => { + const repo = project(); + const { layer, out, http } = settledOnDeploy(repo.dir, "active"); + + return Effect.gen(function* () { + yield* push(); + + expect(http.routeKeys).not.toContain(`GET ${workersRoute("/api")}`); + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("v1"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails on a deploy that came back already failed", () => { + const repo = project(); + const { layer, http } = settledOnDeploy(repo.dir, "failed"); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerBuildFailedError); + expect(http.routeKeys).not.toContain(`GET ${workersRoute("/api")}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + }); + it.live("omits the runtime for a Dockerfile worker and builds from the uploaded context", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, @@ -458,6 +533,19 @@ describe("legacy workers push", () => { }); const withRef = { projectRef: Option.some(WORKERS_PROJECT_REF) }; + it.live("in the still-building trailer under --no-wait", () => { + const repo = project(); + const { layer, out } = unlinked(repo.dir); + + return Effect.gen(function* () { + yield* push({ ...withRef, noWait: true }); + + expect(out.stderrText).toContain( + `supabase experimental workers status api --project-ref ${WORKERS_PROJECT_REF}`, + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("in the failed-build retry suggestion", () => { const repo = project(); const { layer } = unlinked(repo.dir, { @@ -502,23 +590,13 @@ describe("legacy workers push", () => { // noise on a command that already resolves to the right project. it.live("but leaves it off when the link supplied the ref", () => { const repo = project(); - const { layer } = setupLegacyWorkers({ - workdir: repo.dir, - routes: routes({ - [`GET ${workersRoute("/api")}`]: { - status: 200, - body: { data: workerResource({ name: "api", buildState: "failed" }) }, - }, - }), - }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); return Effect.gen(function* () { - const error = yield* push().pipe(Effect.flip); + yield* push({ noWait: true }); - expect((error as WorkerBuildFailedError).suggestion).toContain( - "supabase experimental workers push api", - ); - expect((error as WorkerBuildFailedError).suggestion).not.toContain("--project-ref"); + expect(out.stderrText).toContain("supabase experimental workers status api"); + expect(out.stderrText).not.toContain("--project-ref"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); }); @@ -1066,6 +1144,48 @@ describe("legacy workers push", () => { expect(http.routeKeys).not.toContain(`POST ${workersRoute("/web/deploy")}`); // No summary either — nothing finished. expect(out.stdoutText).not.toContain("Deployed 2 Workers"); + // Nothing was left running: a waiting run has no build in flight to name. + expect(out.stderrText).not.toContain("Still building"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `runCli` drains success trailers only on exit code 0, so a later failure + // discards the follow-up hint for a build that is still running — and the + // failure does nothing to stop that build. The failure path has to say so. + it.live("names the builds a failed --no-wait run left running", () => { + const repo = project({ + "supabase/config.toml": + `project_id = "demo"\n\n[workers.api]\nruntime = "node"\n` + + `\n[workers.web]\nruntime = "node"\n\n[workers.zap]\nruntime = "node"\n`, + "supabase/workers/web/index.js": "export default {};\n", + "supabase/workers/zap/index.js": "export default {};\n", + }); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + // The upload slot points at one URL for every worker, so `web` reuses + // the `PUT` the default routes already stub. + [`POST ${workersRoute("/web/uploads")}`]: { status: 201, body: uploadSlot }, + [`POST ${workersRoute("/web/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "failed" }) }, + }, + }), + }); + + return Effect.gen(function* () { + // Alphabetical: `api` is accepted, `web` fails, `zap` is never reached. + const error = yield* push({ names: [], noWait: true }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerBuildFailedError); + expect(out.stderrText).toContain("Still building: api"); + expect(out.stderrText).toContain("Not attempted: zap"); + // In flight before never started: one is a thing to follow, the other a + // thing to re-run. + expect(out.stderrText.indexOf("Still building")).toBeLessThan( + out.stderrText.indexOf("Not attempted"), + ); + expect(http.routeKeys).not.toContain(`POST ${workersRoute("/zap/deploy")}`); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -1140,6 +1260,115 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // `image_version` is optional-but-permitted on the deploy response, so a + // re-push of a worker that is already serving can echo the image it is + // serving now — the previous build's. Reporting that beside `State building` + // names an image this deploy did not produce. + describe("does not report the previous image while a re-push is still building", () => { + const rePush = (repoDir: string, format?: "json") => + setupLegacyWorkers({ + workdir: repoDir, + ...(format === undefined ? {} : { format }), + routes: routes({ + [`POST ${workersRoute("/api/deploy")}`]: { + status: 202, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "building", + // The worker was already live, so the platform echoes the image + // it is still serving. + imageVersion: "v7", + }), + }, + }, + }), + }); + + it.live("leaves the Image row out of the details block", () => { + const repo = project(); + const { layer, out } = rePush(repo.dir); + + return Effect.gen(function* () { + yield* push({ noWait: true }); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).not.toContain("v7"); + expect(out.stdoutText).not.toContain("Image"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("omits image_version from the payload a script reads", () => { + const repo = project(); + const { layer, out } = rePush(repo.dir, "json"); + + return Effect.gen(function* () { + yield* push({ noWait: true }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + // Whole-payload rather than a missing-key assertion: beside + // `build_state: "building"`, an `image_version` reads as this build's. + expect(success?.data?.["workers"]).toEqual([ + { + worker_name: "api", + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + build_state: "building", + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("still reports the image once the build has settled", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + // The mirror case: blanking is tied to `building`, not to re-pushes. + expect(out.stdoutText).toContain("v1"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + }); + + // Under `--no-wait` the payload reports the accepted deploy rather than a + // finished one: the build has not produced an image, and saying `active` + // would tell a script the worker is already serving. + it.live("reports the build as still running in json mode under --no-wait", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: routes(), + }); + + return Effect.gen(function* () { + yield* push({ noWait: true }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data?.["workers"]).toEqual([ + { + worker_name: "api", + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + build_state: "building", + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // `--output-format json` asked for a stream of events, so progress does not // belong in it — unlike the "not attempted" report, which every format gets. it.live("keeps per-worker progress out of json mode", () => {