From adf34acf1ca1edd6671cb2210742de68aacf002b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:07:58 +0000 Subject: [PATCH 01/15] fix(e2e): poll DNS over DoH so runner DNS caching can't stall challenges Since the E2E workflow moved to Blacksmith runners (ec9fad2), all three e2e tests time out in pollDnsTxtRecord: the Cloudflare TXT records are created successfully, but the runner's system resolver keeps returning empty results for the full 10-minute polling window. Blacksmith routes DNS through its own resolver infrastructure, so freshly created records can stay invisible to Deno.resolveDns far longer than on GitHub-hosted runners. Instead of depending on the runner's DNS path, the e2e tests now poll propagation via the package's own DoH resolver (Cloudflare endpoint), which rides plain HTTPS and behaves the same on any runner. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/create-certificate.test.ts | 2 +- e2e/utils/resolveDns.ts | 11 +++++++++++ e2e/wildcard.test.ts | 2 +- e2e/workflows.test.ts | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 e2e/utils/resolveDns.ts diff --git a/e2e/create-certificate.test.ts b/e2e/create-certificate.test.ts index d429e49..d52fab3 100644 --- a/e2e/create-certificate.test.ts +++ b/e2e/create-certificate.test.ts @@ -6,9 +6,9 @@ import { AcmeOrder, DnsUtils, } from "../src/mod.ts"; -import { resolveDns } from "../src/resolveDns.deno.ts"; import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts new file mode 100644 index 0000000..e2db188 --- /dev/null +++ b/e2e/utils/resolveDns.ts @@ -0,0 +1,11 @@ +import { createResolveDns } from "../../src/resolveDns.doh.ts"; +import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; + +// CI runners (currently Blacksmith) sit behind their own DNS infrastructure, +// where freshly created TXT records can stay invisible to the system resolver +// for longer than our polling timeout. Resolving over DoH (plain HTTPS) +// bypasses the runner's DNS path entirely, so propagation polling behaves the +// same on any runner. +export const resolveDns = createResolveDns({ + endpoint: PUBLIC_DNS.cloudflare.doh[0], +}); diff --git a/e2e/wildcard.test.ts b/e2e/wildcard.test.ts index e4ecd5e..858cb8e 100644 --- a/e2e/wildcard.test.ts +++ b/e2e/wildcard.test.ts @@ -5,10 +5,10 @@ import { AcmeOrder, DnsUtils, } from "../src/mod.ts"; -import { resolveDns } from "../src/resolveDns.deno.ts"; import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e-wildcard@test.acme.pkg.fishball.dev"; diff --git a/e2e/workflows.test.ts b/e2e/workflows.test.ts index 88ff791..ea1171f 100644 --- a/e2e/workflows.test.ts +++ b/e2e/workflows.test.ts @@ -4,9 +4,9 @@ import { AcmeOrder, AcmeWorkflows, } from "@fishballpkg/acme"; -import { resolveDns } from "@fishballpkg/acme/resolveDns.deno"; import { describe, expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e@test.acme.pkg.fishball.dev"; From fd600b547bdc6361d69334f63f7726e80f6d416d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:13:39 +0000 Subject: [PATCH 02/15] Revert "fix(e2e): poll DNS over DoH so runner DNS caching can't stall challenges" This reverts commit adf34acf1ca1edd6671cb2210742de68aacf002b. --- e2e/create-certificate.test.ts | 2 +- e2e/utils/resolveDns.ts | 11 ----------- e2e/wildcard.test.ts | 2 +- e2e/workflows.test.ts | 2 +- 4 files changed, 3 insertions(+), 14 deletions(-) delete mode 100644 e2e/utils/resolveDns.ts diff --git a/e2e/create-certificate.test.ts b/e2e/create-certificate.test.ts index d52fab3..d429e49 100644 --- a/e2e/create-certificate.test.ts +++ b/e2e/create-certificate.test.ts @@ -6,9 +6,9 @@ import { AcmeOrder, DnsUtils, } from "../src/mod.ts"; +import { resolveDns } from "../src/resolveDns.deno.ts"; import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; -import { resolveDns } from "./utils/resolveDns.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts deleted file mode 100644 index e2db188..0000000 --- a/e2e/utils/resolveDns.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createResolveDns } from "../../src/resolveDns.doh.ts"; -import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; - -// CI runners (currently Blacksmith) sit behind their own DNS infrastructure, -// where freshly created TXT records can stay invisible to the system resolver -// for longer than our polling timeout. Resolving over DoH (plain HTTPS) -// bypasses the runner's DNS path entirely, so propagation polling behaves the -// same on any runner. -export const resolveDns = createResolveDns({ - endpoint: PUBLIC_DNS.cloudflare.doh[0], -}); diff --git a/e2e/wildcard.test.ts b/e2e/wildcard.test.ts index 858cb8e..e4ecd5e 100644 --- a/e2e/wildcard.test.ts +++ b/e2e/wildcard.test.ts @@ -5,10 +5,10 @@ import { AcmeOrder, DnsUtils, } from "../src/mod.ts"; +import { resolveDns } from "../src/resolveDns.deno.ts"; import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; -import { resolveDns } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e-wildcard@test.acme.pkg.fishball.dev"; diff --git a/e2e/workflows.test.ts b/e2e/workflows.test.ts index ea1171f..88ff791 100644 --- a/e2e/workflows.test.ts +++ b/e2e/workflows.test.ts @@ -4,9 +4,9 @@ import { AcmeOrder, AcmeWorkflows, } from "@fishballpkg/acme"; +import { resolveDns } from "@fishballpkg/acme/resolveDns.deno"; import { describe, expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; -import { resolveDns } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e@test.acme.pkg.fishball.dev"; From 4df3d9c084e0237fecee690d030a6d9b6519d60c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:13:56 +0000 Subject: [PATCH 03/15] ci(e2e): switch E2E workflow back to GitHub-hosted runner The E2E suite has been failing on Blacksmith runners because their DNS path never surfaces the freshly created challenge TXT records to the test's polling. Move back to ubuntu-latest where the suite passes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- .github/workflows/e2e.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 31035e4..2c1061c 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -10,7 +10,7 @@ on: jobs: build: if: ${{ github.event_name != 'pull_request_target' || github.event.label.name == 'e2e:approve' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: denoland/setup-deno@v2 From 94681342eb1977894af1b16d0152d00f4cbad234 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:21:38 +0000 Subject: [PATCH 04/15] ci(e2e): gate PR e2e runs with a protected environment instead of pull_request_target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pull_request_target ran the workflow and checked out the code from the base branch, so labeled PR runs only ever re-tested main — a PR could never actually exercise its own changes. Switch to a plain pull_request trigger with a protected 'e2e' environment: runs now use the PR's own workflow and code, while secret access still requires a manual approval on every run, including new pushes and fork PRs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- .github/workflows/e2e.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 2c1061c..5d16c93 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -4,13 +4,15 @@ on: push: branches: - main - pull_request_target: - types: [labeled] + pull_request: jobs: build: - if: ${{ github.event_name != 'pull_request_target' || github.event.label.name == 'e2e:approve' }} runs-on: ubuntu-latest + # The `e2e` environment holds CLOUDFLARE_API_KEY / CLOUDFLARE_ZONE_ID and + # requires manual approval, gating secret access for every run (including + # fork PRs) while still testing the PR's own code. + environment: e2e steps: - uses: actions/checkout@v6 - uses: denoland/setup-deno@v2 From 2aa560ff84825ed50ec2338b67b86c885db1be3b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:24:35 +0000 Subject: [PATCH 05/15] Reapply "fix(e2e): poll DNS over DoH so runner DNS caching can't stall challenges" This reverts commit fd600b547bdc6361d69334f63f7726e80f6d416d. --- e2e/create-certificate.test.ts | 2 +- e2e/utils/resolveDns.ts | 11 +++++++++++ e2e/wildcard.test.ts | 2 +- e2e/workflows.test.ts | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 e2e/utils/resolveDns.ts diff --git a/e2e/create-certificate.test.ts b/e2e/create-certificate.test.ts index d429e49..d52fab3 100644 --- a/e2e/create-certificate.test.ts +++ b/e2e/create-certificate.test.ts @@ -6,9 +6,9 @@ import { AcmeOrder, DnsUtils, } from "../src/mod.ts"; -import { resolveDns } from "../src/resolveDns.deno.ts"; import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts new file mode 100644 index 0000000..e2db188 --- /dev/null +++ b/e2e/utils/resolveDns.ts @@ -0,0 +1,11 @@ +import { createResolveDns } from "../../src/resolveDns.doh.ts"; +import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; + +// CI runners (currently Blacksmith) sit behind their own DNS infrastructure, +// where freshly created TXT records can stay invisible to the system resolver +// for longer than our polling timeout. Resolving over DoH (plain HTTPS) +// bypasses the runner's DNS path entirely, so propagation polling behaves the +// same on any runner. +export const resolveDns = createResolveDns({ + endpoint: PUBLIC_DNS.cloudflare.doh[0], +}); diff --git a/e2e/wildcard.test.ts b/e2e/wildcard.test.ts index e4ecd5e..858cb8e 100644 --- a/e2e/wildcard.test.ts +++ b/e2e/wildcard.test.ts @@ -5,10 +5,10 @@ import { AcmeOrder, DnsUtils, } from "../src/mod.ts"; -import { resolveDns } from "../src/resolveDns.deno.ts"; import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e-wildcard@test.acme.pkg.fishball.dev"; diff --git a/e2e/workflows.test.ts b/e2e/workflows.test.ts index 88ff791..ea1171f 100644 --- a/e2e/workflows.test.ts +++ b/e2e/workflows.test.ts @@ -4,9 +4,9 @@ import { AcmeOrder, AcmeWorkflows, } from "@fishballpkg/acme"; -import { resolveDns } from "@fishballpkg/acme/resolveDns.deno"; import { describe, expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e@test.acme.pkg.fishball.dev"; From 27c428f02e18a1deb85f50700365974d7ccf4e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:24:50 +0000 Subject: [PATCH 06/15] ci(e2e): trial Blacksmith runner again, now with DoH-based DNS polling With PR e2e runs now testing PR code, this combination can be verified for real: the DoH resolver should sidestep the Blacksmith DNS issue that broke polling before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- .github/workflows/e2e.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 5d16c93..804bdf1 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -8,7 +8,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 # The `e2e` environment holds CLOUDFLARE_API_KEY / CLOUDFLARE_ZONE_ID and # requires manual approval, gating secret access for every run (including # fork PRs) while still testing the PR's own code. From 5214282a8de2a3b9b2e45198a6528477d255997b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:38:41 +0000 Subject: [PATCH 07/15] fix(e2e): timeout + fallback for DoH polling so stalled requests can't hang the suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Blacksmith trial hung mid-run: the DoH fetch had no timeout, so a silently stalled connection to the resolver endpoint blocked pollDnsTxtRecord forever — its polling budget is only checked between attempts. Add an optional timeout to createResolveDns (resolveDns.doh) via AbortSignal.timeout, and make the e2e resolver try Cloudflare then Google with a 10s per-request timeout, logging failures and returning 'not visible yet' so polling retries. A full DoH outage now surfaces as the regular polling TimeoutError with diagnostics in the log. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/utils/resolveDns.ts | 41 ++++++++++++++++++++++++++++++++++++++--- src/resolveDns.doh.ts | 8 +++++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts index e2db188..03b787a 100644 --- a/e2e/utils/resolveDns.ts +++ b/e2e/utils/resolveDns.ts @@ -1,3 +1,4 @@ +import type { ResolveDnsFunction } from "../../src/DnsUtils/resolveDns.ts"; import { createResolveDns } from "../../src/resolveDns.doh.ts"; import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; @@ -6,6 +7,40 @@ import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; // for longer than our polling timeout. Resolving over DoH (plain HTTPS) // bypasses the runner's DNS path entirely, so propagation polling behaves the // same on any runner. -export const resolveDns = createResolveDns({ - endpoint: PUBLIC_DNS.cloudflare.doh[0], -}); +const ATTEMPT_TIMEOUT_MS = 10_000; + +const RESOLVERS = [ + { + name: "cloudflare", + resolve: createResolveDns({ + endpoint: PUBLIC_DNS.cloudflare.doh[0], + timeout: ATTEMPT_TIMEOUT_MS, + }), + }, + { + name: "google", + resolve: createResolveDns({ + endpoint: PUBLIC_DNS.google.doh[0], + timeout: ATTEMPT_TIMEOUT_MS, + }), + }, +]; + +export const resolveDns: ResolveDnsFunction = async (domain, recordType) => { + for (const { name, resolve } of RESOLVERS) { + try { + return await resolve(domain, recordType); + } catch (error) { + console.warn( + `⚠️ DoH lookup via ${name} failed for ${domain} (${recordType}): ${error}`, + ); + } + } + + // Treat unreachable resolvers as "record not visible yet": + // pollDnsTxtRecord surfaces thrown errors immediately, so returning [] + // keeps it retrying and a full DoH outage shows up as its regular polling + // timeout with the warnings above in the log, instead of aborting the test. + // deno-lint-ignore no-explicit-any -- Empty result preserves the resolver contract. + return [] as any; +}; diff --git a/src/resolveDns.doh.ts b/src/resolveDns.doh.ts index d343bbe..3084566 100644 --- a/src/resolveDns.doh.ts +++ b/src/resolveDns.doh.ts @@ -21,6 +21,11 @@ export type ResolveDnsDohOptions = { * or provide your own compatible endpoint. */ endpoint: string; + /** + * Abort each DoH request after this many milliseconds, rejecting with a + * `TimeoutError` `DOMException`. When omitted, requests have no timeout. + */ + timeout?: number; }; type DnsRecordType = "A" | "AAAA" | "NS" | "TXT"; @@ -61,7 +66,7 @@ const DNS_RESPONSE_CODES = { export const createResolveDns = ( options: ResolveDnsDohOptions, ): ResolveDnsFunction => { - const { endpoint } = options; + const { endpoint, timeout } = options; return async (domain, recordType) => { const url = new URL(endpoint); @@ -72,6 +77,7 @@ export const createResolveDns = ( headers: { Accept: "application/dns-json", }, + signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout), }); if (!res.ok) { From f6b50be0f965019ca76cd0996f38619acbee097e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:41:30 +0000 Subject: [PATCH 08/15] fix(e2e): merge answers from multiple DoH resolvers + propagation grace period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 78 showed the real failure mode: DoH works on Blacksmith (2 of 3 tests passed), but polling starts the instant the Cloudflare API returns, so the first query can hit the resolver edge before the record propagates — the resulting NXDOMAIN gets negative-cached longer than the 10-minute polling budget, leaving one unlucky domain stuck at []. Two mitigations: - resolveDns now queries Cloudflare and Google DoH in parallel and merges answers, so a single resolver's stale negative cache can't stall polling; failures are logged and treated as 'not visible yet'. - Tests wait 15s after creating DNS records before the first lookup, so the negative cache is far less likely to be seeded at all. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/create-certificate.test.ts | 4 ++- e2e/utils/resolveDns.ts | 47 +++++++++++++++++++++++++--------- e2e/wildcard.test.ts | 4 ++- e2e/workflows.test.ts | 3 ++- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/e2e/create-certificate.test.ts b/e2e/create-certificate.test.ts index d52fab3..69415ac 100644 --- a/e2e/create-certificate.test.ts +++ b/e2e/create-certificate.test.ts @@ -8,7 +8,7 @@ import { } from "../src/mod.ts"; import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; -import { resolveDns } from "./utils/resolveDns.ts"; +import { resolveDns, waitForDnsPropagationGrace } from "./utils/resolveDns.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; @@ -53,6 +53,8 @@ it("can talk to ACME server and successfully create an account, order then all t }]); console.log("⏳ Creating DNS record for _acme-challenge..."); + await waitForDnsPropagationGrace(); + await DnsUtils.pollDnsTxtRecord(expectedRecord.name, { pollUntil: expectedRecord.content, resolveDns, diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts index 03b787a..50f3542 100644 --- a/e2e/utils/resolveDns.ts +++ b/e2e/utils/resolveDns.ts @@ -26,21 +26,44 @@ const RESOLVERS = [ }, ]; +/** + * Queries all resolvers in parallel and merges their answers: a record counts + * as propagated as soon as ANY public resolver sees it. A resolver that got an + * early NXDOMAIN stuck in its negative cache would otherwise stall polling for + * the zone's full negative TTL — longer than the polling budget. (The ACME + * server validates against the zone's authoritative servers anyway, so one + * resolver seeing the record is a good-enough propagation signal for e2e.) + */ export const resolveDns: ResolveDnsFunction = async (domain, recordType) => { - for (const { name, resolve } of RESOLVERS) { - try { - return await resolve(domain, recordType); - } catch (error) { + const results = await Promise.allSettled( + RESOLVERS.map(({ resolve }) => resolve(domain, recordType)), + ); + + const answerss = results.flatMap((result, i) => { + if (result.status === "rejected") { console.warn( - `⚠️ DoH lookup via ${name} failed for ${domain} (${recordType}): ${error}`, + `⚠️ DoH lookup via ${ + RESOLVERS[i]?.name + } failed for ${domain} (${recordType}): ${result.reason}`, ); + return []; } - } + return [result.value]; + }); - // Treat unreachable resolvers as "record not visible yet": - // pollDnsTxtRecord surfaces thrown errors immediately, so returning [] - // keeps it retrying and a full DoH outage shows up as its regular polling - // timeout with the warnings above in the log, instead of aborting the test. - // deno-lint-ignore no-explicit-any -- Empty result preserves the resolver contract. - return [] as any; + // All resolvers failed: treat as "record not visible yet" so + // pollDnsTxtRecord retries (it surfaces thrown errors immediately) — a full + // DoH outage then shows up as its regular polling timeout with the warnings + // above in the log, instead of aborting the test. + // deno-lint-ignore no-explicit-any -- Merged result preserves the resolver contract. + return answerss.flat() as any; }; + +/** + * Give the DNS provider a moment to propagate freshly created records to its + * authoritative edge before the first lookup. Querying too early seeds public + * resolvers' negative caches with NXDOMAIN, which can outlive the polling + * budget entirely. + */ +export const waitForDnsPropagationGrace = (): Promise => + new Promise((res) => setTimeout(res, 15_000)); diff --git a/e2e/wildcard.test.ts b/e2e/wildcard.test.ts index 858cb8e..571770e 100644 --- a/e2e/wildcard.test.ts +++ b/e2e/wildcard.test.ts @@ -8,7 +8,7 @@ import { import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; -import { resolveDns } from "./utils/resolveDns.ts"; +import { resolveDns, waitForDnsPropagationGrace } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e-wildcard@test.acme.pkg.fishball.dev"; @@ -63,6 +63,8 @@ it("can talk to ACME server and successfully retrieve a wildcard certificate", a await cloudflareZone.createDnsRecords(dnsTxtRecords); console.log("⏳ Creating DNS records for _acme-challenge..."); + await waitForDnsPropagationGrace(); + await DnsUtils.pollDnsTxtRecord(`_acme-challenge.${DOMAIN}.`, { pollUntil: dnsTxtRecords.map(({ content }) => content), resolveDns, diff --git a/e2e/workflows.test.ts b/e2e/workflows.test.ts index ea1171f..57973fc 100644 --- a/e2e/workflows.test.ts +++ b/e2e/workflows.test.ts @@ -6,7 +6,7 @@ import { } from "@fishballpkg/acme"; import { describe, expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; -import { resolveDns } from "./utils/resolveDns.ts"; +import { resolveDns, waitForDnsPropagationGrace } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e@test.acme.pkg.fishball.dev"; @@ -35,6 +35,7 @@ describe("requestCertificates", () => { domains: DOMAINS, updateDnsRecords: async (dnsRecords) => { await cloudflareZone.createDnsRecords(dnsRecords); + await waitForDnsPropagationGrace(); }, resolveDns, }); From d408f30a196309e369818343acabe8de60a85fe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:47:39 +0000 Subject: [PATCH 09/15] test(e2e): drop DNS propagation grace period Trial whether the merged multi-resolver DoH polling alone is enough to keep e2e green on Blacksmith, without the extra 15s wait after record creation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/create-certificate.test.ts | 4 +--- e2e/utils/resolveDns.ts | 9 --------- e2e/wildcard.test.ts | 4 +--- e2e/workflows.test.ts | 3 +-- 4 files changed, 3 insertions(+), 17 deletions(-) diff --git a/e2e/create-certificate.test.ts b/e2e/create-certificate.test.ts index 69415ac..d52fab3 100644 --- a/e2e/create-certificate.test.ts +++ b/e2e/create-certificate.test.ts @@ -8,7 +8,7 @@ import { } from "../src/mod.ts"; import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; -import { resolveDns, waitForDnsPropagationGrace } from "./utils/resolveDns.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; @@ -53,8 +53,6 @@ it("can talk to ACME server and successfully create an account, order then all t }]); console.log("⏳ Creating DNS record for _acme-challenge..."); - await waitForDnsPropagationGrace(); - await DnsUtils.pollDnsTxtRecord(expectedRecord.name, { pollUntil: expectedRecord.content, resolveDns, diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts index 50f3542..142d4e0 100644 --- a/e2e/utils/resolveDns.ts +++ b/e2e/utils/resolveDns.ts @@ -58,12 +58,3 @@ export const resolveDns: ResolveDnsFunction = async (domain, recordType) => { // deno-lint-ignore no-explicit-any -- Merged result preserves the resolver contract. return answerss.flat() as any; }; - -/** - * Give the DNS provider a moment to propagate freshly created records to its - * authoritative edge before the first lookup. Querying too early seeds public - * resolvers' negative caches with NXDOMAIN, which can outlive the polling - * budget entirely. - */ -export const waitForDnsPropagationGrace = (): Promise => - new Promise((res) => setTimeout(res, 15_000)); diff --git a/e2e/wildcard.test.ts b/e2e/wildcard.test.ts index 571770e..858cb8e 100644 --- a/e2e/wildcard.test.ts +++ b/e2e/wildcard.test.ts @@ -8,7 +8,7 @@ import { import { expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; import { expectToBeDefined } from "./utils/expectToBeDefined.ts"; -import { resolveDns, waitForDnsPropagationGrace } from "./utils/resolveDns.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e-wildcard@test.acme.pkg.fishball.dev"; @@ -63,8 +63,6 @@ it("can talk to ACME server and successfully retrieve a wildcard certificate", a await cloudflareZone.createDnsRecords(dnsTxtRecords); console.log("⏳ Creating DNS records for _acme-challenge..."); - await waitForDnsPropagationGrace(); - await DnsUtils.pollDnsTxtRecord(`_acme-challenge.${DOMAIN}.`, { pollUntil: dnsTxtRecords.map(({ content }) => content), resolveDns, diff --git a/e2e/workflows.test.ts b/e2e/workflows.test.ts index 57973fc..ea1171f 100644 --- a/e2e/workflows.test.ts +++ b/e2e/workflows.test.ts @@ -6,7 +6,7 @@ import { } from "@fishballpkg/acme"; import { describe, expect, it } from "../test_deps.ts"; import { CloudflareZone } from "./utils/cloudflare.ts"; -import { resolveDns, waitForDnsPropagationGrace } from "./utils/resolveDns.ts"; +import { resolveDns } from "./utils/resolveDns.ts"; import { randomFishballTestingSubdomain } from "./utils/randomFishballTestingSubdomain.ts"; const EMAIL = "e2e@test.acme.pkg.fishball.dev"; @@ -35,7 +35,6 @@ describe("requestCertificates", () => { domains: DOMAINS, updateDnsRecords: async (dnsRecords) => { await cloudflareZone.createDnsRecords(dnsRecords); - await waitForDnsPropagationGrace(); }, resolveDns, }); From b82a9a5bc3bd6b4613972f55f0dac774b8d3d84a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:03:17 +0000 Subject: [PATCH 10/15] fix(e2e): purge 1.1.1.1 cache on empty DNS answers instead of merging Google DoH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the e2e resolver back to Cloudflare DoH only. When a lookup returns empty, flush the record from 1.1.1.1 via the purge endpoint behind one.one.one.one/purge-cache, so a negatively cached NXDOMAIN seeded by a lookup that raced record propagation can't outlive the polling budget — the next attempt re-queries the authoritative servers. The purge endpoint is unofficial; lookup and purge failures are logged and treated as 'record not visible yet' so polling keeps retrying. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/utils/resolveDns.ts | 98 +++++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts index 142d4e0..ea0cf98 100644 --- a/e2e/utils/resolveDns.ts +++ b/e2e/utils/resolveDns.ts @@ -9,52 +9,66 @@ import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; // same on any runner. const ATTEMPT_TIMEOUT_MS = 10_000; -const RESOLVERS = [ - { - name: "cloudflare", - resolve: createResolveDns({ - endpoint: PUBLIC_DNS.cloudflare.doh[0], - timeout: ATTEMPT_TIMEOUT_MS, - }), - }, - { - name: "google", - resolve: createResolveDns({ - endpoint: PUBLIC_DNS.google.doh[0], - timeout: ATTEMPT_TIMEOUT_MS, - }), - }, -]; +const dohResolveDns = createResolveDns({ + endpoint: PUBLIC_DNS.cloudflare.doh[0], + timeout: ATTEMPT_TIMEOUT_MS, +}); /** - * Queries all resolvers in parallel and merges their answers: a record counts - * as propagated as soon as ANY public resolver sees it. A resolver that got an - * early NXDOMAIN stuck in its negative cache would otherwise stall polling for - * the zone's full negative TTL — longer than the polling budget. (The ACME - * server validates against the zone's authoritative servers anyway, so one - * resolver seeing the record is a good-enough propagation signal for e2e.) + * Flushes a record from 1.1.1.1's cache via the endpoint behind + * https://one.one.one.one/purge-cache/. + * + * Unofficial and undocumented — Cloudflare may gate or change it without + * notice. If e2e starts timing out on DNS polling with purge warnings in the + * log, this endpoint is the first thing to check. */ +const purgeCloudflareDnsCache = async ( + domain: string, + recordType: string, +): Promise => { + const url = new URL("https://one.one.one.one/api/v1/purge"); + url.searchParams.set("domain", domain.replace(/\.$/, "")); + url.searchParams.set("type", recordType); + + const res = await fetch(url, { + method: "POST", + signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS), + }); + await res.text(); // consume the body to avoid leaking the connection + + if (!res.ok) { + throw new Error(`unexpected response: ${res.status} ${res.statusText}`); + } +}; + export const resolveDns: ResolveDnsFunction = async (domain, recordType) => { - const results = await Promise.allSettled( - RESOLVERS.map(({ resolve }) => resolve(domain, recordType)), - ); - - const answerss = results.flatMap((result, i) => { - if (result.status === "rejected") { - console.warn( - `⚠️ DoH lookup via ${ - RESOLVERS[i]?.name - } failed for ${domain} (${recordType}): ${result.reason}`, - ); - return []; + try { + const records = await dohResolveDns(domain, recordType); + + if (records.length === 0) { + // An empty answer may be a negatively cached NXDOMAIN seeded by a + // lookup that raced record propagation — 1.1.1.1 would otherwise keep + // serving it for the zone's full negative TTL, which outlives the + // polling budget. Purge so the next attempt re-queries the + // authoritative servers. + try { + await purgeCloudflareDnsCache(domain, recordType); + } catch (error) { + console.warn( + `⚠️ Failed to purge 1.1.1.1 cache for ${domain} (${recordType}): ${error}`, + ); + } } - return [result.value]; - }); - // All resolvers failed: treat as "record not visible yet" so - // pollDnsTxtRecord retries (it surfaces thrown errors immediately) — a full - // DoH outage then shows up as its regular polling timeout with the warnings - // above in the log, instead of aborting the test. - // deno-lint-ignore no-explicit-any -- Merged result preserves the resolver contract. - return answerss.flat() as any; + return records; + } catch (error) { + console.warn( + `⚠️ DoH lookup failed for ${domain} (${recordType}): ${error}`, + ); + // Treat failures as "record not visible yet" so pollDnsTxtRecord retries + // (it surfaces thrown errors immediately) — an outage then shows up as + // its regular polling timeout with the warnings above in the log. + // deno-lint-ignore no-explicit-any -- Empty result preserves the resolver contract. + return [] as any; + } }; From 6c37da2e46ff65597f8136493db89644fc2c0a70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:06:05 +0000 Subject: [PATCH 11/15] chore(e2e): log 1.1.1.1 cache purges during DNS polling Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/utils/resolveDns.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts index ea0cf98..95cb802 100644 --- a/e2e/utils/resolveDns.ts +++ b/e2e/utils/resolveDns.ts @@ -52,6 +52,9 @@ export const resolveDns: ResolveDnsFunction = async (domain, recordType) => { // polling budget. Purge so the next attempt re-queries the // authoritative servers. try { + console.log( + `🧹 Purging 1.1.1.1 cache for ${domain} (${recordType})...`, + ); await purgeCloudflareDnsCache(domain, recordType); } catch (error) { console.warn( From de9d7875b8a88c903c41156c752c7315d390b7db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:07:16 +0000 Subject: [PATCH 12/15] docs(ci): update e2e environment comment to match unprotected-env setup The environment no longer has required reviewers; fork runs are gated by the repo-level 'require approval for all outside collaborators' setting instead. Describe what the environment actually does now. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- .github/workflows/e2e.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 804bdf1..8f1e391 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -9,9 +9,12 @@ on: jobs: build: runs-on: blacksmith-2vcpu-ubuntu-2404 - # The `e2e` environment holds CLOUDFLARE_API_KEY / CLOUDFLARE_ZONE_ID and - # requires manual approval, gating secret access for every run (including - # fork PRs) while still testing the PR's own code. + # The `e2e` environment holds CLOUDFLARE_API_KEY / CLOUDFLARE_ZONE_ID. + # Environment secrets (unlike repo secrets) are also passed to fork PR + # runs, which is what allows external contributions to run e2e. Fork runs + # are gated by the repo's "require approval for all outside collaborators" + # Actions setting — approving such a run hands the PR's code these + # secrets, so review the diff first. environment: e2e steps: - uses: actions/checkout@v6 From 67617a5449ebfa56f8c7ce6904147bb8ae743227 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:12:01 +0000 Subject: [PATCH 13/15] refactor: drop DoH timeout option, raise default DNS poll interval to 10s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeout option never addressed an observed failure — remove it to keep the resolveDns.doh API surface unchanged. Keep a hardcoded abort on the (unofficial) 1.1.1.1 purge request only. Bump pollDnsTxtRecord's default interval from 5s to 10s: DNS propagation is slow enough that 5s polling mostly burns queries (and now purge requests) without finding records sooner. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/utils/resolveDns.ts | 7 +++---- src/DnsUtils/pollDnsTxtRecord.ts | 4 ++-- src/resolveDns.doh.ts | 8 +------- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts index 95cb802..4348f14 100644 --- a/e2e/utils/resolveDns.ts +++ b/e2e/utils/resolveDns.ts @@ -7,11 +7,8 @@ import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; // for longer than our polling timeout. Resolving over DoH (plain HTTPS) // bypasses the runner's DNS path entirely, so propagation polling behaves the // same on any runner. -const ATTEMPT_TIMEOUT_MS = 10_000; - const dohResolveDns = createResolveDns({ endpoint: PUBLIC_DNS.cloudflare.doh[0], - timeout: ATTEMPT_TIMEOUT_MS, }); /** @@ -32,7 +29,9 @@ const purgeCloudflareDnsCache = async ( const res = await fetch(url, { method: "POST", - signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS), + // This endpoint is unofficial, so don't let a stalled request block the + // polling loop indefinitely. + signal: AbortSignal.timeout(10_000), }); await res.text(); // consume the body to avoid leaking the connection diff --git a/src/DnsUtils/pollDnsTxtRecord.ts b/src/DnsUtils/pollDnsTxtRecord.ts index 2622763..8d391c8 100644 --- a/src/DnsUtils/pollDnsTxtRecord.ts +++ b/src/DnsUtils/pollDnsTxtRecord.ts @@ -12,7 +12,7 @@ export type PollDnsTxtRecordOptions = { /** * Delay (ms) between DNS lookup attempts. * - * Default: `5000` + * Default: `10000` */ interval?: number; /** @@ -134,5 +134,5 @@ ${JSON.stringify(latestRecordss, null, 2)} Expected record: ${pollUntil}`); }; -const DEFAULT_INTERVAL = 5_000; +const DEFAULT_INTERVAL = 10_000; const DEFAULT_TIMEOUT = 10 * 60_000; diff --git a/src/resolveDns.doh.ts b/src/resolveDns.doh.ts index 3084566..d343bbe 100644 --- a/src/resolveDns.doh.ts +++ b/src/resolveDns.doh.ts @@ -21,11 +21,6 @@ export type ResolveDnsDohOptions = { * or provide your own compatible endpoint. */ endpoint: string; - /** - * Abort each DoH request after this many milliseconds, rejecting with a - * `TimeoutError` `DOMException`. When omitted, requests have no timeout. - */ - timeout?: number; }; type DnsRecordType = "A" | "AAAA" | "NS" | "TXT"; @@ -66,7 +61,7 @@ const DNS_RESPONSE_CODES = { export const createResolveDns = ( options: ResolveDnsDohOptions, ): ResolveDnsFunction => { - const { endpoint, timeout } = options; + const { endpoint } = options; return async (domain, recordType) => { const url = new URL(endpoint); @@ -77,7 +72,6 @@ export const createResolveDns = ( headers: { Accept: "application/dns-json", }, - signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout), }); if (!res.ok) { From a07b02ca4d0d20909cd149b12911a4e01abcfdba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:16:22 +0000 Subject: [PATCH 14/15] refactor(e2e): strip DNS polling to the minimal DoH resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the 1.1.1.1 purge-on-empty logic and restore the 5s default poll interval, leaving only the Cloudflare DoH resolver — the minimal change over the system resolver — to find out how much machinery the Blacksmith fix actually needs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/utils/resolveDns.ts | 67 +------------------------------- src/DnsUtils/pollDnsTxtRecord.ts | 4 +- 2 files changed, 3 insertions(+), 68 deletions(-) diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts index 4348f14..e2db188 100644 --- a/e2e/utils/resolveDns.ts +++ b/e2e/utils/resolveDns.ts @@ -1,4 +1,3 @@ -import type { ResolveDnsFunction } from "../../src/DnsUtils/resolveDns.ts"; import { createResolveDns } from "../../src/resolveDns.doh.ts"; import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; @@ -7,70 +6,6 @@ import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; // for longer than our polling timeout. Resolving over DoH (plain HTTPS) // bypasses the runner's DNS path entirely, so propagation polling behaves the // same on any runner. -const dohResolveDns = createResolveDns({ +export const resolveDns = createResolveDns({ endpoint: PUBLIC_DNS.cloudflare.doh[0], }); - -/** - * Flushes a record from 1.1.1.1's cache via the endpoint behind - * https://one.one.one.one/purge-cache/. - * - * Unofficial and undocumented — Cloudflare may gate or change it without - * notice. If e2e starts timing out on DNS polling with purge warnings in the - * log, this endpoint is the first thing to check. - */ -const purgeCloudflareDnsCache = async ( - domain: string, - recordType: string, -): Promise => { - const url = new URL("https://one.one.one.one/api/v1/purge"); - url.searchParams.set("domain", domain.replace(/\.$/, "")); - url.searchParams.set("type", recordType); - - const res = await fetch(url, { - method: "POST", - // This endpoint is unofficial, so don't let a stalled request block the - // polling loop indefinitely. - signal: AbortSignal.timeout(10_000), - }); - await res.text(); // consume the body to avoid leaking the connection - - if (!res.ok) { - throw new Error(`unexpected response: ${res.status} ${res.statusText}`); - } -}; - -export const resolveDns: ResolveDnsFunction = async (domain, recordType) => { - try { - const records = await dohResolveDns(domain, recordType); - - if (records.length === 0) { - // An empty answer may be a negatively cached NXDOMAIN seeded by a - // lookup that raced record propagation — 1.1.1.1 would otherwise keep - // serving it for the zone's full negative TTL, which outlives the - // polling budget. Purge so the next attempt re-queries the - // authoritative servers. - try { - console.log( - `🧹 Purging 1.1.1.1 cache for ${domain} (${recordType})...`, - ); - await purgeCloudflareDnsCache(domain, recordType); - } catch (error) { - console.warn( - `⚠️ Failed to purge 1.1.1.1 cache for ${domain} (${recordType}): ${error}`, - ); - } - } - - return records; - } catch (error) { - console.warn( - `⚠️ DoH lookup failed for ${domain} (${recordType}): ${error}`, - ); - // Treat failures as "record not visible yet" so pollDnsTxtRecord retries - // (it surfaces thrown errors immediately) — an outage then shows up as - // its regular polling timeout with the warnings above in the log. - // deno-lint-ignore no-explicit-any -- Empty result preserves the resolver contract. - return [] as any; - } -}; diff --git a/src/DnsUtils/pollDnsTxtRecord.ts b/src/DnsUtils/pollDnsTxtRecord.ts index 8d391c8..2622763 100644 --- a/src/DnsUtils/pollDnsTxtRecord.ts +++ b/src/DnsUtils/pollDnsTxtRecord.ts @@ -12,7 +12,7 @@ export type PollDnsTxtRecordOptions = { /** * Delay (ms) between DNS lookup attempts. * - * Default: `10000` + * Default: `5000` */ interval?: number; /** @@ -134,5 +134,5 @@ ${JSON.stringify(latestRecordss, null, 2)} Expected record: ${pollUntil}`); }; -const DEFAULT_INTERVAL = 10_000; +const DEFAULT_INTERVAL = 5_000; const DEFAULT_TIMEOUT = 10 * 60_000; From 9e42c31ba4d13379e83c5beaebaa2a9ff49f2188 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:30:33 +0000 Subject: [PATCH 15/15] fix(e2e): purge 1.1.1.1 cache before every DNS lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare Cloudflare DoH polling failed twice (runs 78, 86) with a TXT record stuck at [] for the whole 10-minute budget — an early lookup racing record propagation gets its NXDOMAIN negatively cached for longer than we can afford to wait. Purge before each lookup so every poll attempt effectively re-queries the authoritative servers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJeFA4CinTHKcfKHwCE7a9 --- e2e/utils/resolveDns.ts | 51 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/e2e/utils/resolveDns.ts b/e2e/utils/resolveDns.ts index e2db188..bc4779b 100644 --- a/e2e/utils/resolveDns.ts +++ b/e2e/utils/resolveDns.ts @@ -1,3 +1,4 @@ +import type { ResolveDnsFunction } from "../../src/DnsUtils/resolveDns.ts"; import { createResolveDns } from "../../src/resolveDns.doh.ts"; import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; @@ -6,6 +7,54 @@ import { PUBLIC_DNS } from "../../src/resolveDns.nameServers.ts"; // for longer than our polling timeout. Resolving over DoH (plain HTTPS) // bypasses the runner's DNS path entirely, so propagation polling behaves the // same on any runner. -export const resolveDns = createResolveDns({ +const dohResolveDns = createResolveDns({ endpoint: PUBLIC_DNS.cloudflare.doh[0], }); + +/** + * Flushes a record from 1.1.1.1's cache via the endpoint behind + * https://one.one.one.one/purge-cache/. + * + * Unofficial and undocumented — Cloudflare may gate or change it without + * notice. If e2e starts timing out on DNS polling with purge warnings in the + * log, this endpoint is the first thing to check. + */ +const purgeCloudflareDnsCache = async ( + domain: string, + recordType: string, +): Promise => { + const url = new URL("https://one.one.one.one/api/v1/purge"); + url.searchParams.set("domain", domain.replace(/\.$/, "")); + url.searchParams.set("type", recordType); + + const res = await fetch(url, { + method: "POST", + // This endpoint is unofficial, so don't let a stalled request block the + // polling loop indefinitely. + signal: AbortSignal.timeout(10_000), + }); + await res.text(); // consume the body to avoid leaking the connection + + if (!res.ok) { + throw new Error(`unexpected response: ${res.status} ${res.statusText}`); + } +}; + +export const resolveDns: ResolveDnsFunction = async (domain, recordType) => { + // Purge 1.1.1.1's cache before every lookup so polling keeps re-querying + // the authoritative servers: a lookup that races record propagation would + // otherwise cache NXDOMAIN for the zone's full negative TTL, which outlives + // the polling budget (observed in e2e runs 78 and 86). The purge applies + // asynchronously on Cloudflare's side, so a lookup may still see the cache + // as it was one attempt ago — polling converges an attempt later. + try { + console.log(`🧹 Purging 1.1.1.1 cache for ${domain} (${recordType})...`); + await purgeCloudflareDnsCache(domain, recordType); + } catch (error) { + console.warn( + `⚠️ Failed to purge 1.1.1.1 cache for ${domain} (${recordType}): ${error}`, + ); + } + + return await dohResolveDns(domain, recordType); +};