fix: create the DNS record the custom-domain step never made - #11
Merged
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
POST /accounts/{account}/pages/projects/{project}/domainsregisters ahostname with a Pages project. It does not create a DNS record — and it answers
success: truefor the half it did. The dashboard hides the difference: adding acustom 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:
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 ashas_domainfinds the hostname, which is precisely thestate 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_domainguard 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
Arecord occupying the name is equally there and equallynot this workflow's to replace.
The zone is resolved by name, never hardcoded.
domain-suffixis 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, thenheroiclands.org— andtakes the first real zone. A consumer publishing under
example.net(where thesuffix 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 FQDN —
kethira.pkgin zoneheroiclands.org. Cloudflare accepts either on create; the seven records made byhand 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 everyexisting 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: falseis a failure, Cloudflare's own error codes arerelayed, 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, inaddition 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_TOKENsecret description, and the README's setup notes (atwo-row table, plus step 3 of Adding a package).
What was verified, and what was not
Verified by execution.
actionlint1.7.12 withshellcheck0.11.0 over every workflow in therepository: clean.
Two negative controls proving the linter reaches the new script, not just
the file. Injecting
probe=$(echo $DOMAIN)into the new step producesSC2086 … at 6:14— shellcheck resolving line 6 of the dedented blockscalar. Renaming the new step's
inputs.domain-suffixtodomain-suffizproduces
property "domain-suffiz" is not defined in object type {…}, soactionlint is type-checking the new step's expressions against the declared
workflow_callinterface.The step's own text, extracted from the YAML with
yq(so the block-scalardedent is applied) and run against a stubbed
curl— 41 assertions across11 cases, all passing:
POSTto the resolved zone id, payload{"type":"CNAME","name":"kethira.pkg","content":"sohl-kethira-basic.pages.dev","proxied":true}, zone walked suffix → parent, exit 0POST, no warning, exit 0POST, exit 0Arecord and its content, noPOST, exit 0400 success:false9999: Bad request, and the consequence403on the create403on the zone lookup403on the record lookup81053)domain-suffix, suffix is the zoneharnensemble, contentharn-ensemble.pages.devTwo mutation controls on the harness itself, to show it tests the
workflow's text rather than agreeing with itself: changing the payload to
proxied: falsefails exactly one assertion, and changingrecord="${DOMAIN%".${zone}"}"torecord="${DOMAIN}"fails exactly the twosubdomain-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:
GET /zones?name=…returns the zone for a token scoped toZone → 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.
another run. The step does not key on any code for that path (it re-lists), so
this affects the message, not the behaviour.
<package>.pkgis what makes the router's origin fetchwork — 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-adventuresandhm3as untouched cases — that is nolonger 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 changesnothing.
Closes #10