Designs by Duhart All writing

·4 min read·Cloudflare · Pages · CI/CD · wrangler

The working directory is part of the deploy

This site is a static export. Every page is prerendered at build time and served as a file, which means there is no server to instrument, no runtime to scale, and nothing to keep warm. It also means the two endpoints that do need to run somewhere — a contact form and a checkout session — cannot live inside the app. They are Pages Functions, and they sit in a functions/ directory at the repository root, next to the app rather than inside it.

That separation is correct. next build does not know those files exist and should not; they are a different runtime with a different lifecycle. The cost of the separation is that the deploy now has two inputs, and the command that performs it names only one of them.

wrangler pages deploy site/out --project-name <project> --branch main

Read that command and you will conclude it uploads site/out. It does. It also uploads ./functions, which appears nowhere in the line, because the Functions directory is resolved relative to the current working directory rather than relative to the output directory you named. Run the command from the repository root and the API ships. Run the identical command one directory down, with the output path adjusted so it still points at the same files, and the API does not. Same command, same artifacts, same exit code, different site.

For a long time this was invisible, because the deploy ran from a shell script that began with cd "$(dirname "$0")". The working directory was fixed by accident of how the script was invoked, and an accident that always produces the right answer is indistinguishable from a design until something moves.

What moved

Continuous deployment moved it. In CI, every job chooses its own working directory, and the two halves of this build want different ones. The build has to run inside the app directory: it reads its blog posts from a sibling of that directory via a relative path, so the process's own location is load-bearing. The deploy has to run from the repository root, or the Functions are dropped. Encoding both as one shared setup step breaks whichever one you did not think about last.

The failure has a specific and unhelpful shape. The build passes, including every gate — link checking, redaction scanning, the lot. The deploy prints a success line and exits zero. The site loads. Every page renders, every link resolves, every image appears. The only thing wrong is that a form which worked yesterday now posts into a 404, and nothing anywhere reports an error, because nothing involved believes anything went wrong.

That is worth sitting with. The pipeline was green at every stage, and the greenness was accurate: the build did build, and the deploy did deploy. What exit zero established was that a directory of files was uploaded successfully. It established nothing whatsoever about which files, and the difference between those two claims is the entire bug.

Naming the unnamed input

There are two fixes and they catch different things.

The first is an assertion before the deploy runs. Confirm the working directory is the repository root, confirm functions/ is visible from it, and fail loudly if either is untrue. This is cheap and it fires at exactly the right moment — before anything ships, with a message that names the actual problem instead of leaving you to infer it from a 404 three hours later. Its limit is that it only catches the failure you already thought of.

The second is to stop trusting the deploy's own report and ask the deployed thing directly. After production updates, POST an empty JSON body to the contact endpoint and require a 400 back. A 400 is the endpoint saying "name, email and message are all required" — which proves it is not merely present but running its validation. A 404 proves it never shipped. The check is safe to run on every deploy because the validation it trips sits ahead of the parts with consequences: no mail is sent, no bot-check quota is spent, no payment API is touched. The endpoint fails closed, so probing it costs nothing.

The same pipeline now asserts that a path exists which only the current build produces. An older generator for this site wrote its output somewhere else, and a stale configuration file still names that other directory for the same project. Today the command-line argument wins. That is a behaviour of one pinned version of one tool, not a guarantee, and the honest way to depend on it is to pin the version and then verify the outcome anyway.

The general form

A deploy command's arguments are not the same thing as its inputs. Anything the tool resolves from ambient state — the working directory, the environment, a configuration file discovered by walking upward — is an input you did not name, and therefore one you are not tracking. Locally that is usually fine, because ambient state is stable when one person runs one script from one place. Automation is precisely the act of changing where things run. It is very good at finding every input you left implicit.

If any of this saved you an afternoon, Buy me a coffee.