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.