From c2cd92e5fe18c4f82824b43f3f7b9f5494d250bc Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:24:01 +0300 Subject: [PATCH] fix(cli): retry post-deploy calls while the new deployment token spreads Workers Builds makes a new deployment token on every build. Right after `wrangler deploy` the edge can still answer from the previous admin Worker version, so the push check got 401 and the build failed although both Workers had deployed (cloudhal.com, 0.3.9 update). The push and the primary-domain calls after a deploy now retry on 401 for up to 90 s. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/deploy-token-propagation.md | 5 +++ packages/cli/src/commands/deploy/resources.ts | 25 +++++++++++ packages/cli/src/commands/deploy/ship.ts | 44 +++++++++++-------- packages/cli/test/token-propagation.test.ts | 42 ++++++++++++++++++ 4 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 .changeset/deploy-token-propagation.md create mode 100644 packages/cli/test/token-propagation.test.ts diff --git a/.changeset/deploy-token-propagation.md b/.changeset/deploy-token-propagation.md new file mode 100644 index 00000000..8e19c8ed --- /dev/null +++ b/.changeset/deploy-token-propagation.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/cli": patch +--- + +`cloudhal deploy` retries its post-deploy calls (push check, primary domain) for up to 90 seconds while they get 401: a Workers Builds deploy makes a new deployment token, and the edge can answer from the previous admin Worker version for a few seconds. Such a build used to fail after both Workers had deployed. diff --git a/packages/cli/src/commands/deploy/resources.ts b/packages/cli/src/commands/deploy/resources.ts index 5f1d46af..73be4dbd 100644 --- a/packages/cli/src/commands/deploy/resources.ts +++ b/packages/cli/src/commands/deploy/resources.ts @@ -139,6 +139,31 @@ export async function waitForHealth( } } +/** + * Runs `step` again while it fails with 401. Right after a deploy the edge can still answer from + * the previous admin Worker version, which holds the previous deployment token (Workers Builds + * makes a new one on every build), for a few seconds. Gives up after `timeoutMs`. + */ +export async function whileTokenPropagates( + step: () => Promise, + options: { log: (line: string) => void; timeoutMs?: number; waitMs?: number }, +): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 90_000); + let announced = false; + for (;;) { + try { + return await step(); + } catch (e) { + if (!/\(401\)/.test(e instanceof Error ? e.message : "") || Date.now() > deadline) throw e; + if (!announced) { + options.log("the deployment does not accept the new token yet; retrying while it spreads…"); + announced = true; + } + await new Promise((r) => setTimeout(r, options.waitMs ?? 5000)); + } + } +} + /** Purges the site Worker's whole cache once (the script tag lives in cached HTML). */ export async function purgeSite( siteUrl: string, diff --git a/packages/cli/src/commands/deploy/ship.ts b/packages/cli/src/commands/deploy/ship.ts index 7db233cf..ebc8a646 100644 --- a/packages/cli/src/commands/deploy/ship.ts +++ b/packages/cli/src/commands/deploy/ship.ts @@ -14,7 +14,7 @@ import type { import { deployedUrls, isCronLimitError, must, WranglerError } from "../../cloudflare/wrangler.ts"; import { pushSite, setPrimaryDomain } from "../sync.ts"; import type { DeployContext } from "./context.ts"; -import { purgeSite, waitForHealth } from "./resources.ts"; +import { purgeSite, waitForHealth, whileTokenPropagates } from "./resources.ts"; import { CRON_REFUSED_MESSAGE } from "./schedules-guard.ts"; import type { DeploySecrets } from "./state.ts"; @@ -111,29 +111,35 @@ export async function afterDeploy( }); await l.close(); } - const result = await pushSite({ - projectDir: options.projectDir, - ifAbsent: true, - log, - ...(options.fetch ? { fetch: options.fetch } : {}), - }); - // A custom domain becomes the site's primary hostname (the workers.dev one stays secondary). - if (cfg.domains?.site) { - await setPrimaryDomain({ + const retry = (step: () => Promise) => whileTokenPropagates(step, { log }); + const result = await retry(() => + pushSite({ projectDir: options.projectDir, - hostname: cfg.domains.site, + ifAbsent: true, + log, ...(options.fetch ? { fetch: options.fetch } : {}), - }); - log( - `site domain: https://${cfg.domains.site} (Cloudflare issues the certificate on first use)`, + }), + ); + // A custom domain becomes the site's primary hostname (the workers.dev one stays secondary). + const domain = cfg.domains?.site; + if (domain) { + await retry(() => + setPrimaryDomain({ + projectDir: options.projectDir, + hostname: domain, + ...(options.fetch ? { fetch: options.fetch } : {}), + }), ); + log(`site domain: https://${domain} (Cloudflare issues the certificate on first use)`); } else if (previous?.siteDomain && siteUrl) { // The custom domain was removed from cloudhal.json: workers.dev is primary again. - await setPrimaryDomain({ - projectDir: options.projectDir, - hostname: new URL(siteUrl).hostname, - ...(options.fetch ? { fetch: options.fetch } : {}), - }); + await retry(() => + setPrimaryDomain({ + projectDir: options.projectDir, + hostname: new URL(siteUrl).hostname, + ...(options.fetch ? { fetch: options.fetch } : {}), + }), + ); log( `site domain ${previous.siteDomain} removed from cloudhal.json; if Cloudflare still lists it under the Worker's Domains & Routes, remove it there`, ); diff --git a/packages/cli/test/token-propagation.test.ts b/packages/cli/test/token-propagation.test.ts new file mode 100644 index 00000000..cbf5f808 --- /dev/null +++ b/packages/cli/test/token-propagation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { whileTokenPropagates } from "../src/commands/deploy/resources.ts"; + +describe("whileTokenPropagates (a new deployment token takes seconds to reach every edge)", () => { + it("retries a step that fails with 401, then returns its result", async () => { + let calls = 0; + const lines: string[] = []; + const result = await whileTokenPropagates( + async () => { + calls++; + if (calls < 3) throw new Error('push failed (401): {"error":"unauthorized"}'); + return "pushed"; + }, + { log: (l) => lines.push(l), waitMs: 1 }, + ); + expect(result).toBe("pushed"); + expect(calls).toBe(3); + expect(lines).toHaveLength(1); + }); + + it("does not retry other failures, and gives up after the timeout", async () => { + let calls = 0; + await expect( + whileTokenPropagates( + async () => { + calls++; + throw new Error("push failed (500): boom"); + }, + { log: () => {}, waitMs: 1 }, + ), + ).rejects.toThrow(/500/); + expect(calls).toBe(1); + await expect( + whileTokenPropagates( + async () => { + throw new Error("push failed (401)"); + }, + { log: () => {}, waitMs: 1, timeoutMs: 20 }, + ), + ).rejects.toThrow(/401/); + }); +});