diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 31035e4..8f1e391 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -4,13 +4,18 @@ 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: blacksmith-2vcpu-ubuntu-2404 + # 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 - uses: denoland/setup-deno@v2 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..bc4779b --- /dev/null +++ b/e2e/utils/resolveDns.ts @@ -0,0 +1,60 @@ +import type { ResolveDnsFunction } from "../../src/DnsUtils/resolveDns.ts"; +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. +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); +}; 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";