Deployment
How a commit becomes the live site - the staging step, the wrangler command and why it takes no positional directory, the separate cron-worker deploy, and how to roll back.
Last updated 2026-09-14
Summary#
A push to main deploys production. There is no approval, no staging gate on this repository, no build step and no test run: a GitHub Actions job copies files into dist/ and hands that directory to wrangler pages deploy. The whole thing takes about a minute.
A push to redesign runs the same workflow but lands as a preview at redesign.metricvaultai.pages.dev and never touches the live site. Everything else, including the cron scheduler, is a separate deploy.
Warning: main is production, and nothing checks a push before it ships. Run node tests/run-gates.mjs and confirm your branch with git branch --show-current before every push.
Purpose#
The deploy is deliberately dumb. Because the repository has no compiler and no bundler, the safest possible publish is a file copy: fewer moving parts means fewer ways for what ships to differ from what you reviewed. The two pieces of real logic in the workflow both exist because of production incidents, and both are explained below rather than trusted silently.
Requirements#
| Requirement | Detail |
|---|---|
| Push access to the repository | main for production, redesign for the homepage preview |
CLOUDFLARE_API_TOKEN | Repository secret. Without it the deploy step fails and nothing ships |
CLOUDFLARE_ACCOUNT_ID | Repository secret. Pages config cannot hold account_id, so the workflow supplies it |
| A green local gate run | node tests/run-gates.mjs. The workflow does not run it, so a failure you skip ships. See Verification scripts |
Permissions#
The workflow requests contents: read and deployments: write. Anyone who can push to main can deploy production, so branch discipline is the only control in place. Changing the Cloudflare project's environment variables requires access to the Cloudflare dashboard for the project metricvaultai; changing how the deploy authenticates requires repository-secret access on GitHub. Neither is needed to ship code.
Navigation Path#
.github/workflows/deploy.yml the production deploy
.github/workflows/cron.yml the backup scheduler
tests/release-gates.txt the checks to run before a push
tests/run-gates.mjs runs that list
wrangler.toml project name, output dir, bindings
cron-worker/ a separate Worker with its own deployStep-by-Step Guide#
1. Confirm where you are#
git branch --show-currentA parallel session can move HEAD in a shared working tree. Stage explicit paths and never git add -A, which sweeps up another developer's work in progress. See Code conventions and file ownership.
2. Run the release gates#
node tests/run-gates.mjs # every check in tests/release-gates.txt
node tests/run-gates.mjs --bail # stop at the first failureNothing else runs them. Until 2026-09-14 the deploy workflow ran this list in a Run the release gates step, and a failure stopped the deploy. The owner removed testing from GitHub Actions to save minutes, so a push now ships whether or not these pass. The run ends with release gates: N passed, 0 failed; if it lists failures, do not push.
Translation coverage (tests/i18n-verify.mjs) runs at the end as an advisory check. It prints but never fails the run: fixing it needs tests/i18n-generate.mjs and ANTHROPIC_API_KEY, and a gap means some text renders in English, which is degraded, not broken.
3. Push#
git push origin mainThe workflow triggers on a push to main or redesign, and can also be started by hand with workflow_dispatch from the Actions tab.
4. What the workflow does#
Four steps, in order:
| Step | Detail |
|---|---|
Check out repository | actions/checkout@v4, full history, which the per-page <lastmod> step needs |
Set up Node | actions/setup-node@v4, node-version: "22" |
Stage assets into dist/ | The whole "build". See below |
Deploy to Cloudflare Pages | npx --yes wrangler@latest pages deploy --project-name=metricvaultai --branch="${{ github.ref_name }}" --commit-dirty=true --commit-hash="${{ github.sha }}" |
The staging step, in order:
mkdir -p dist, thennode tests/sitemap-lastmod.mjs --writeand copysitemap-lastmod.json, so each page's sitemap date is its real commit date.- Copy a named list of root files:
_worker.js,blog-embed.js,manifest.json,chat-widget.js,pwa-install.js,chatbot.js,icon-192.svg,icon-512.svg,icon-192.png,icon-512.png,favicon.ico,favicon-16x16.png,favicon-32x32.png,apple-touch-icon.png,logo-dark.png,logo-light.png,og-image.png,robots.txt,sitemap.xml,_headers,_routes.json. - Copy every top-level
*.html. This catch-all exists so the named list can never silently drop a page again. - Copy the directory trees
icons free-tools reports legal js css images help platform solutionsrecursively. Each is load-bearing:index.htmlneedsimages/,dashboard.htmlneedscss/andjs/,404.htmlneedscss/. ext-preview.htmlis deliberately staged by the catch-all in step 3, because the worker serves it.- Write
dist/version.jsonwith the commit, branch and build time. : > dist/.assetsignore— writes an empty file. The repository-root.assetsignoreis therefore inert for this deploy path, since wrangler reads the copy in the output directory.ls -la dist/— the deploy log's manifest of exactly what shipped. Read this when a file is missing in production.
Important: Step 4 says "stage by directory, never by filename" for a reason. This repository once staged a hardcoded 24-file allowlist. Anything ported from staging that introduced a new file shipped its HTML but not its assets, and 404'd in production.
5. Why the wrangler command has no positional directory#
npx --yes wrangler@latest pages deploy \
--project-name=metricvaultai \
--branch="${{ github.ref_name }}" \
--commit-dirty=true \
--commit-hash="${{ github.sha }}"Wrangler reads pages_build_output_dir ("dist") from wrangler.toml and applies the D1, KV, R2 and Workers AI bindings declared there to the deployment. Passing dist/ positionally skips those bindings, and the live site then fails with MONITOR_DB not bound. Wrangler may need a redeploy. Do not add the argument back.
--branch="${{ github.ref_name }}" is what makes main production and every other branch a preview on its own URL. --commit-hash means any Cloudflare deployment can be mapped back to an exact commit.
6. Verify#
Load the live page and confirm the change. curl -s https://metricvaultai.com/version.json names the commit that is live, written by the workflow from GITHUB_SHA at deploy time.
There is no service worker and nothing to bump. sw.js was removed on 2026-09-03: it was registered only by admin.html and the six legal pages, while dashboard.html and login.html each unregistered it on load, so it was downloaded and then destroyed on the next navigation and delivered nothing to anyone. A stale client asset is now evicted by the ?v= content hash that tests/asset-version-sync.mjs stamps on every /css/* and /js/* reference, which changes when the file changes rather than when somebody remembers.
The workflow used to check after every deploy that the sign-in page refuses cleartext HTTP. That check was removed with the other tests. To run it by hand:
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://metricvaultai.com/login
curl -sSI https://metricvaultai.com/login | grep -i strict-transport-securityThe first should print 301 https://metricvaultai.com/login and the second a Strict-Transport-Security header. If not, an edge rule on the zone is overriding "Always Use HTTPS"; it cannot be fixed from this repository.
For a deeper check, run the live smoke test against production:
BASE_URL=https://metricvaultai.com node tests/smoke.mjsIt exercises 45 real tool calls, so it costs real provider spend. Use --tools= to narrow it. See Verification scripts.
What does not ship#
The staging step is an allowlist, so anything outside it stays behind.
| Path | Why it does not ship |
|---|---|
partials/ | Source for the nav and footer. Stamped into pages before commit |
i18n/ | Pipeline source. The shipped dictionaries are js/mv-i18n-dict.<lang>.js, one per language, inside js/ |
docs/, including docs/kb/ | Documentation source |
help/ | The generated knowledge-base portal. Staged by the directory loop and served live at /help/. Never hand-edited: edit docs/kb/ and run node tests/kb-build.mjs --write |
tests/ | Verification tooling |
cron-worker/ | A separate Worker with its own deploy |
chrome-extension/ | Extension source |
*.sql | Manual Supabase setup, run by hand |
.dev.vars, .wrangler/, dist/ | Git-ignored |
The cron-worker is a second, separate deploy#
Cloudflare Pages never invokes scheduled(), so the background jobs need an external clock. cron-worker/ is a standalone Cloudflare Worker whose only job is to POST to /api/cron/run every 15 minutes. It is not deployed by the Pages workflow.
One-time setup:
cd cron-worker
wrangler secret put MV_INTERNAL_SECRET # must match the Pages project's value
wrangler deployVerify:
curl https://metricvault-cron.<your-subdomain>.workers.dev # expects {"ok":true,"status":200,...}
wrangler tail metricvault-cron # live logsTo change the cadence, edit crons in cron-worker/wrangler.toml and run wrangler deploy again. .github/workflows/cron.yml is a backup that runs every six hours and only exists to catch up if the primary is down; because the jobs self-throttle in the database, running both causes no double work. Full detail is in Background jobs and scheduling.
Important: After changing MV_INTERNAL_SECRET, update all three copies in the same sitting: the Pages project, the cron-worker secret, and the GitHub repository secret. A mismatch is silent until a job stops running. See Environment variables and secrets.
Rollback#
There is no rollback script and no automated rollback step in this repository. What exists:
| Mechanism | How | When to use it |
|---|---|---|
| Revert and push | git revert <sha> && git push origin main | The default. Produces a new deployment of the previous content |
| Re-run the workflow | Actions tab, Run workflow on deploy.yml | When the deploy itself failed but the code is fine |
| Cloudflare deployment history | The Pages dashboard's rollback control | Cloudflare-side. Nothing in the repository references it |
| Backup branches | Named backup-<what>-<UTC timestamp> on the remote | The de-facto snapshot before a risky change |
Three hazards specific to this repository:
- Reverting
_worker.jscan un-sync the inlinedLOGIN_HTML. Always runnode tests/login-inline-sync.mjsafter a revert, and--writeif it reports drift. - A code revert does not revert the database. Schemas are created lazily with
CREATE TABLE IF NOT EXISTSandALTER TABLE ADD COLUMNinside the worker, so rolling the code back leaves the new columns and tables in place. That is intentional and it is what makes a rollback safe. See Schema migrations. - Client caches survive a rollback. A rolled-back CSS or JS file returns to its earlier content hash, so its
?v=reverts with it and browsers fetch the older copy. HTML isno-store, so pages themselves come back immediately.
Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
MONITOR_DB not bound. Wrangler may need a redeploy. on the live site | The deploy passed a positional directory, so the bindings were skipped, or wrangler.toml was converted to Workers format | Restore the exact wrangler command. Confirm wrangler.toml is Pages format with pages_build_output_dir = "dist" |
| A page loads but its CSS or images 404 | The asset directory is not in the staging loop | Add the directory to the for d in ... list, not the filename list |
| A new page 404s entirely | It is not a top-level .html, or it lives in a directory that is not staged | Move it to the root, or add its directory |
| The deploy step fails immediately | CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is missing or expired | Re-add the repository secrets |
| A broken change reached the live site | The release gates were not run before the push; the workflow no longer runs them | Fix or revert, then node tests/run-gates.mjs before pushing again |
| The deploy succeeded but users see the old UI | A /css/* or /js/* file changed without its ?v= being restamped, so browsers keep the cached copy for up to four hours | node tests/asset-version-sync.mjs --write, commit, push. The release gates catch this if you run them |
Edits to login.html are not live | /login is served from the inlined LOGIN_HTML | node tests/login-inline-sync.mjs --write, commit both files, push |
| A nav or footer fix did not appear | The chrome is stamped from partials/ | node tests/site-chrome-sync.mjs --write, commit, push |
| AI article generation returns 503 | MV_INTERNAL_SECRET is unset or mismatched | Set the same value in all three locations |
| Background jobs stopped | The cron-worker was never redeployed, or its secret drifted | cd cron-worker && wrangler deploy, then check with a manual GET |
| The workflow did not trigger | The branch is not main or redesign | Push to one of those, or run it manually with workflow_dispatch |
FAQs#
Is there a staging environment? Not on this repository. metricvaultai-seo is a separate staging codebase with its own deploy, and work is ported here after it lands there. See Production and staging repos. Within this repository, a preview branch is the closest equivalent: any branch added to the trigger list deploys to <branch>.metricvaultai.pages.dev with the same bindings.
Do the tests run in CI? No. They did until 2026-09-14, as a Run the release gates step that stopped the deploy on a failure, plus an advisory translation check and a post-deploy HTTPS check of the sign-in page. All three were removed to stop spending GitHub Actions minutes on testing. Run node tests/run-gates.mjs before every push; it is the only check.
How long does a deploy take? About a minute: a checkout, a Node setup, the file copy and one wrangler call. Propagation is Cloudflare's normal Pages behavior.
Can I deploy without pushing? Yes, with workflow_dispatch from the Actions tab, but it deploys whatever main currently points at. It re-runs the deploy; it does not deploy your local tree.
Why is there an empty .assetsignore in dist/? The workflow truncates it so no exclusion rules are inherited into the published output. The repository-root .assetsignore is not copied and has no effect on this deploy path. Its trailing comment line exists only to change the file's bytes and force a redeploy when needed.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.