Skip to content
Metric VaultHelp Center
Open app

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

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#

RequirementDetail
Push access to the repositorymain for production, redesign for the homepage preview
CLOUDFLARE_API_TOKENRepository secret. Without it the deploy step fails and nothing ships
CLOUDFLARE_ACCOUNT_IDRepository secret. Pages config cannot hold account_id, so the workflow supplies it
A green local gate runnode 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.

text
.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 deploy

Step-by-Step Guide#

1. Confirm where you are#

bash
git branch --show-current

A 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#

bash
node tests/run-gates.mjs          # every check in tests/release-gates.txt
node tests/run-gates.mjs --bail   # stop at the first failure

Nothing 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#

bash
git push origin main

The 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:

StepDetail
Check out repositoryactions/checkout@v4, full history, which the per-page <lastmod> step needs
Set up Nodeactions/setup-node@v4, node-version: "22"
Stage assets into dist/The whole "build". See below
Deploy to Cloudflare Pagesnpx --yes wrangler@latest pages deploy --project-name=metricvaultai --branch="${{ github.ref_name }}" --commit-dirty=true --commit-hash="${{ github.sha }}"

The staging step, in order:

  1. mkdir -p dist, then node tests/sitemap-lastmod.mjs --write and copy sitemap-lastmod.json, so each page's sitemap date is its real commit date.
  2. 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.
  3. Copy every top-level *.html. This catch-all exists so the named list can never silently drop a page again.
  4. Copy the directory trees icons free-tools reports legal js css images help platform solutions recursively. Each is load-bearing: index.html needs images/, dashboard.html needs css/ and js/, 404.html needs css/.
  5. ext-preview.html is deliberately staged by the catch-all in step 3, because the worker serves it.
  6. Write dist/version.json with the commit, branch and build time.
  7. : > dist/.assetsignore — writes an empty file. The repository-root .assetsignore is therefore inert for this deploy path, since wrangler reads the copy in the output directory.
  8. ls -la dist/ — the deploy log's manifest of exactly what shipped. Read this when a file is missing in production.
Important

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#

bash
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:

bash
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-security

The 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:

bash
BASE_URL=https://metricvaultai.com node tests/smoke.mjs

It 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.

PathWhy 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
*.sqlManual 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:

bash
cd cron-worker
wrangler secret put MV_INTERNAL_SECRET   # must match the Pages project's value
wrangler deploy

Verify:

bash
curl https://metricvault-cron.<your-subdomain>.workers.dev   # expects {"ok":true,"status":200,...}
wrangler tail metricvault-cron                               # live logs

To 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

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:

MechanismHowWhen to use it
Revert and pushgit revert <sha> && git push origin mainThe default. Produces a new deployment of the previous content
Re-run the workflowActions tab, Run workflow on deploy.ymlWhen the deploy itself failed but the code is fine
Cloudflare deployment historyThe Pages dashboard's rollback controlCloudflare-side. Nothing in the repository references it
Backup branchesNamed backup-<what>-<UTC timestamp> on the remoteThe de-facto snapshot before a risky change

Three hazards specific to this repository:

  1. Reverting _worker.js can un-sync the inlined LOGIN_HTML. Always run node tests/login-inline-sync.mjs after a revert, and --write if it reports drift.
  2. A code revert does not revert the database. Schemas are created lazily with CREATE TABLE IF NOT EXISTS and ALTER TABLE ADD COLUMN inside 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.
  3. 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 is no-store, so pages themselves come back immediately.

Troubleshooting#

SymptomLikely causeFix
MONITOR_DB not bound. Wrangler may need a redeploy. on the live siteThe deploy passed a positional directory, so the bindings were skipped, or wrangler.toml was converted to Workers formatRestore the exact wrangler command. Confirm wrangler.toml is Pages format with pages_build_output_dir = "dist"
A page loads but its CSS or images 404The asset directory is not in the staging loopAdd the directory to the for d in ... list, not the filename list
A new page 404s entirelyIt is not a top-level .html, or it lives in a directory that is not stagedMove it to the root, or add its directory
The deploy step fails immediatelyCLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is missing or expiredRe-add the repository secrets
A broken change reached the live siteThe release gates were not run before the push; the workflow no longer runs themFix or revert, then node tests/run-gates.mjs before pushing again
The deploy succeeded but users see the old UIA /css/* or /js/* file changed without its ?v= being restamped, so browsers keep the cached copy for up to four hoursnode 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_HTMLnode tests/login-inline-sync.mjs --write, commit both files, push
A nav or footer fix did not appearThe chrome is stamped from partials/node tests/site-chrome-sync.mjs --write, commit, push
AI article generation returns 503MV_INTERNAL_SECRET is unset or mismatchedSet the same value in all three locations
Background jobs stoppedThe cron-worker was never redeployed, or its secret driftedcd cron-worker && wrangler deploy, then check with a manual GET
The workflow did not triggerThe branch is not main or redesignPush 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?