Skip to content

fix: create the DNS record the custom-domain step never made - #11

Merged
toastygm merged 1 commit into
mainfrom
bug/10_custom-domain-dns-record
Aug 30, 2026
Merged

fix: create the DNS record the custom-domain step never made#11
toastygm merged 1 commit into
mainfrom
bug/10_custom-domain-dns-record

Conversation

@toastygm

Copy link
Copy Markdown
Contributor

POST /accounts/{account}/pages/projects/{project}/domains registers a
hostname with a Pages project. It does not create a DNS record
— and it answers
success: true for the half it did. The dashboard hides the difference: adding a
custom domain by hand shows a Confirm new DNS record screen, and Activate
domain
writes the CNAME as well as registering the name. So four packages
deployed on 2026-08-29 with every step green and all four addresses were
NXDOMAIN until seven CNAMEs were created by hand.

This adds a step that creates the record after the hostname is registered:

POST /zones/{zone}/dns_records
{ "type": "CNAME", "name": "<package>.pkg", "content": "<project>.pages.dev", "proxied": true }

The decisions

It is a separate step, and that is what makes it work. The obvious place is
the tail of "Ensure the custom domain is on the project" — but that step
exit 0s as soon as has_domain finds the hostname, which is precisely the
state this bug leaves behind. All six live packages now have a registered domain,
so folded in, the fix would never run for a single one of them. It sits
immediately after that step and before the upload, so the address resolves by the
time the deployment lands.

Idempotent by inspection, in the shape of the has_domain guard it follows:
list the records for the name, create only what is absent, and re-check rather
than key on an error code if a create loses a race. Matched on the name alone,
not name-and-type: an A record occupying the name is equally there and equally
not this workflow's to replace.

The zone is resolved by name, never hardcoded. domain-suffix is an input,
and the zone is a suffix of the suffix rather than equal to it, so it walks the
name a label at a time — pkg.heroiclands.org, then heroiclands.org — and
takes the first real zone. A consumer publishing under example.net (where the
suffix is the zone) resolves in one step. Nothing found up the whole chain
fails the run, naming both possibilities: the token cannot see the zone, or the
suffix names a domain the account does not host.

The record name is the subdomain, not the FQDNkethira.pkg in zone
heroiclands.org. Cloudflare accepts either on create; the seven records made by
hand are in the short form, and one shape across the set is what keeps them
comparable.

proxied: true. The router fetches these hostnames as origins, and every
existing record is proxied. An existing record that is unproxied gets a warning
rather than a rewrite.

A failure here fails the run, including a permissions failure. A green deploy
that leaves the address unreachable is the entire bug, so nothing is tolerated: a
non-2xx or a success: false is a failure, Cloudflare's own error codes are
relayed, and every message ends by saying the address does not resolve. A 401/403
on any of the three calls names the permission. A 403 on the create is reported
immediately rather than being re-checked as a race, so the failure is not
misattributed to the lookup.

A record that exists pointing somewhere else is warned about, not overwritten
and not fatal.
This step creates a record that is missing; it does not overwrite
state somebody set deliberately, and failing on it would make a package
undeployable until someone deleted a record. The warning names what is there and
says that record is why the prefix does not reach the project. Flag it if you'd
rather that were an error — it is the one judgement call here.

The token permission, now settled by evidence

The token needs Zone → DNS → Edit on the zone behind domain-suffix, in
addition to Account → Cloudflare Pages → Edit. A token with Pages access
alone reproduces this bug exactly — green run, hostname registered, no record.
That is stated in three places: the workflow's header comment, the
CLOUDFLARE_API_TOKEN secret description, and the README's setup notes (a
two-row table, plus step 3 of Adding a package).

What was verified, and what was not

Verified by execution.

  • actionlint 1.7.12 with shellcheck 0.11.0 over every workflow in the
    repository: clean.

  • Two negative controls proving the linter reaches the new script, not just
    the file. Injecting probe=$(echo $DOMAIN) into the new step produces
    SC2086 … at 6:14 — shellcheck resolving line 6 of the dedented block
    scalar
    . Renaming the new step's inputs.domain-suffix to domain-suffiz
    produces property "domain-suffiz" is not defined in object type {…}, so
    actionlint is type-checking the new step's expressions against the declared
    workflow_call interface.

  • The step's own text, extracted from the YAML with yq (so the block-scalar
    dedent is applied) and run against a stubbed curl — 41 assertions across
    11 cases, all passing:

    Case Result
    record absent POST to the resolved zone id, payload {"type":"CNAME","name":"kethira.pkg","content":"sohl-kethira-basic.pages.dev","proxied":true}, zone walked suffix → parent, exit 0
    record present and correct no POST, no warning, exit 0
    record present but unproxied warns about the proxy, no POST, exit 0
    record present pointing elsewhere warns naming the A record and its content, no POST, exit 0
    create returns 400 success:false fails, naming the call, the code, Cloudflare's own 9999: Bad request, and the consequence
    403 on the create fails naming Zone → DNS → Edit and Pages → Edit; does not re-list
    403 on the zone lookup fails naming the zone lookup and the permission
    403 on the record lookup fails naming the permission
    no zone anywhere up the suffix fails after trying both candidates and no more
    create loses a race (81053) re-checks, finds it, exit 0
    different domain-suffix, suffix is the zone resolves in one lookup; record name harnensemble, content harn-ensemble.pages.dev
  • Two mutation controls on the harness itself, to show it tests the
    workflow's text rather than agreeing with itself: changing the payload to
    proxied: false fails exactly one assertion, and changing
    record="${DOMAIN%".${zone}"}" to record="${DOMAIN}" fails exactly the two
    subdomain-name assertions.

Not verified. I have no Cloudflare credentials and cannot run a GitHub
Actions workflow, so no call in this step has been made against the real API. In
particular:

  • That GET /zones?name=… returns the zone for a token scoped to
    Zone → DNS → Edit on it. If it does not, the run fails with the
    "no Cloudflare zone found" message rather than doing something wrong — but the
    message would be misleading.
  • The exact status code and error body Cloudflare returns for a create that races
    another run. The step does not key on any code for that path (it re-lists), so
    this affects the message, not the behaviour.
  • That a proxied CNAME at <package>.pkg is what makes the router's origin fetch
    work — that is asserted from the seven records already in place, not tested.

And a note on re-verifying in production. The issue names kethira,
harn-ensemble, harn-adventures and hm3 as untouched cases — that is no
longer true. All four deployed today and all four domains now have records, so
the create path can only be re-verified by deleting one record and re-running
that package's deploy
. That is worth doing, and it exercises both paths in
order: the first run creates it (dig + a 200), the second finds it and changes
nothing.

Closes #10

`POST /accounts/{account}/pages/projects/{project}/domains` registers a
hostname with a Pages project. It does not create a DNS record, and it
answers `success: true` for the half it did. Four packages deployed on
2026-08-29 with every step green and all four addresses were NXDOMAIN
until seven CNAMEs were made by hand.

Add a step that creates the record after the hostname is registered:
CNAME <package>.pkg -> <project>.pages.dev, proxied, in the zone resolved
by name from `domain-suffix`. Idempotent by inspection, in the shape of
the `has_domain` guard it follows, and failing loudly — a 401/403 names
the missing Zone -> DNS -> Edit permission rather than being tolerated,
because a green run leaving an unreachable address is the bug itself.

A separate step rather than the tail of the existing one: that step
returns early when the hostname is already registered, which is exactly
the state the bug leaves behind, so folding it in would skip every
package that already has the problem.

Also states both required token permissions in the workflow's comments,
its `CLOUDFLARE_API_TOKEN` description, and the README's setup notes.

Closes #10
@toastygm
toastygm merged commit 4037da3 into main Aug 30, 2026
1 check passed
@toastygm
toastygm deleted the bug/10_custom-domain-dns-record branch August 30, 2026 00:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The custom-domain step registers the hostname but never creates the DNS record, so a green deploy publishes an unreachable address

1 participant