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/); + }); +});