Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

354 lines · 33.8 KBRaw History

title: Deploy your own Diffinery description: Deploy Diffinery to your own Cloudflare account and domain with the Deploy workflow and its scripts, sign in as the first admin, and develop locally.

Diffinery runs entirely on Cloudflare, so you can run your own instance on your own Cloudflare account and domain. This page walks through a first deployment with the repository's Deploy workflow and scripts, the first administrator sign-in, email and DNS setup, updates, local development and the errors the scripts print. Examples use git.example.com as your instance's hostname; replace it with yours.

For what each Cloudflare product does in Diffinery, see Architecture. For every setting, see Configuration.

What you need

RequirementWhy
A Cloudflare account on the Workers Paid planDiffinery uses Containers (hosted runners, sandboxes and the Git transfers of GitHub imports), Durable Objects with SQLite, Workflows, Queues, D1, R2, Cron Triggers and Email Sending. Containers need the paid plan, and the Worker asks for 300,000 ms of CPU time per request.
R2 enabled on the accountLarge git objects, LFS objects, logs, caches, artifacts, container images, release assets, cached source archives and profile pictures are stored in R2.
A domain added to the account as a zoneDiffinery serves https://<hostname>/* through a Workers route on that zone.
A Cloudflare Zero Trust organizationCloudflare Access guards /auth/access, the email-based sign-in that the first administrator uses and that lets people recover a lost passkey. Its one-time PIN login works without extra setup.
A copy of the Diffinery repository on a forge with GitHub-compatible ActionsThe Deploy workflow (.github/workflows/deploy.yml) runs on ubuntu-latest runners: GitHub, or a Diffinery instance. You can also run the same steps from your own machine (see Deploy from your own machine).

Tools the workflow installs or expects on its runner, and that you need for a manual deploy: Node.js 22, pnpm 10 (the repository pins pnpm@10.28.0), jq, curl, openssl and Docker (wrangler builds the runner container image from containers/runner/Dockerfile during wrangler deploy).

Nobody who uses your instance needs a Cloudflare account. Only the deployment uses one.

How a deployment works

One workflow run does everything, and every step is safe to repeat:

  1. Install dependencies, build the runner agent, the web UI's client bundles and the documentation (the pages under apps/diffinery/docs, the CLI reference and the CLI file served at /-/cli/diffinery.mjs), typecheck and run the unit tests.
  2. scripts/bootstrap.sh creates whatever is missing in your account (D1 database, R2 bucket, queues, the Cloudflare Access application for /auth/access), sets the R2 bucket's lifecycle rules, checks Email Sending, writes apps/diffinery/wrangler.deploy.jsonc with the real IDs and your hostname's route, PUBLIC_URL and email sender, and applies D1 migrations.
  3. wrangler deploy -c wrangler.deploy.jsonc uploads the Worker, the Durable Object classes, the two Workflows (workflow runs and GitHub imports) and the cron trigger, and builds and pushes the runner image for Containers.
  4. scripts/ensure-secrets.sh creates the Worker's two secrets, MASTER_KEY and OIDC_PRIVATE_KEY, the first time only.
  5. On the very first deploy, bootstrap runs again to finish the Access setup, and the Worker is redeployed.
  6. Smoke tests check /healthz, the sign-in pages, static assets and D1 sessions.

Step 1: Point the code at your domain

The repository is set up for diffinery.com. Your instance's hostname comes from the DIFFINERY_DOMAIN repository variable (step 3): on every deploy, scripts/bootstrap.sh passes it to scripts/render-config.mjs, which writes the Worker's route, PUBLIC_URL and MAIL_FROM into the deployed config (see scripts/render-config.mjs). You don't need to change those three values in wrangler.jsonc, or the Deploy workflow; the deploy replaces them. Before your first deploy, change this in your copy.

apps/diffinery/wrangler.jsonc: replace the BOOTSTRAP_ADMINS list with your own.

{
  "vars": {
    // PUBLIC_URL and MAIL_FROM are replaced on deploy; leave them as they are.
    "PUBLIC_URL": "https://diffinery.com",
    "MAIL_FROM": "notifications@diffinery.com",
    "BOOTSTRAP_ADMINS": "you@example.com",
    "ACCESS_TEAM_DOMAIN": "",
    "ACCESS_AUD": ""
  }
}
  • BOOTSTRAP_ADMINS: comma-separated emails of the people who become site administrators when they first sign in. Replace the existing list with your own.
  • Leave ACCESS_TEAM_DOMAIN and ACCESS_AUD empty; bootstrap fills them in.
  • routes, PUBLIC_URL and MAIL_FROM can stay as they are. The deployed config gets a route for <DIFFINERY_DOMAIN>/*, PUBLIC_URL set to https://<DIFFINERY_DOMAIN> and MAIL_FROM set to notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>. You can still edit them to match, so that the file reads correctly, but the deploy doesn't use their values.

.github/workflows/deploy.yml needs no changes. It reads your hostname from repository variables you add in step 3:

  • DIFFINERY_DOMAIN: your hostname, for example git.example.com. scripts/bootstrap.sh uses it for the Worker's route, PUBLIC_URL, the email sender, the Access application and the Email Sending check, and the workflow uses it as the production environment's URL and for its checks after the deploy. Without it, everything assumes diffinery.com, and the deployed Worker gets the diffinery.com/* route and PUBLIC_URL https://diffinery.com whatever wrangler.jsonc says.
  • DIFFINERY_ZONE, if the Cloudflare zone that holds your hostname is not its last two labels. For git.example.com the default zone is example.com, which is right; for git.example.co.uk the default would be co.uk, so set DIFFINERY_ZONE to example.co.uk.
  • MAIL_DOMAIN, if email should come from another domain than your hostname, for example example.com to send from notifications@example.com.

The Deploy job runs on every push to main when your copy lives on a Diffinery instance, and whenever you start it by hand. On GitHub it doesn't run on pushes; start it by hand there (workflow_dispatch).

DIFFINERY_DOMAIN is a bare hostname, without https:// or a path. Diffinery is always served over HTTPS at the root of that hostname.

Other workflows. .github/workflows/live-test.yml and .github/workflows/sync-to-diffinery.yml test and mirror the public diffinery.com instance. Delete them, or change their DIFFINERY host and organization names before you enable them. The sync workflow runs only on GitHub: it copies each pushed branch or tag to the instance, copies the deploy secrets and the DIFFINERY_DOMAIN, DIFFINERY_ZONE, MAIL_DOMAIN and ACCESS_TEAM_DOMAIN variables, and for main waits for the Actions runs there, so the copy on Diffinery deploys itself (see Configuration). ci.yml (typecheck, tests and the end-to-end test) works as is. dind-probe.yml is a maintenance probe started by hand; you can delete it. d1-replication.yml is optional; see Architecture.

Note: The scripts use fixed resource names: a D1 database diffinery, an R2 bucket diffinery-data, queues diffinery-events, diffinery-events-dlq, diffinery-mail and diffinery-mail-dlq, and a Worker diffinery. One Cloudflare account holds one instance unless you rename them in scripts/bootstrap.sh and wrangler.jsonc.

Step 2: Create a Cloudflare API token

In the Cloudflare dashboard, create an API token (My Profile → API Tokens → Create Token → Create Custom Token) with these permissions:

KindPermissionLevelUsed for
AccountWorkers ScriptsEditDeploying the Worker, Durable Objects and secrets
AccountD1EditCreating the database and applying migrations
AccountR2 (Workers R2 Storage)EditCreating the bucket and setting its lifecycle rules
AccountQueuesEditCreating the queues and consumers
AccountWorkflowsEditThe diffinery-run and diffinery-import Workflows
AccountContainersEditBuilding and deploying the runner image for runners, sandboxes and imports
AccountAccess: Apps and PoliciesEditThe Access application for /auth/access
Zone (the zone that holds your hostname, DIFFINERY_ZONE)Workers RoutesEditThe git.example.com/* route
Zone (your zone), optionalZone Read, DNS Read, Email Sending ReadReadLetting scripts/email-sending.sh check your Email Sending setup through the API. Without them it falls back to public DNS lookups.

If the token can't read your Zero Trust organization, bootstrap can't discover your Access team domain on its own; set the ACCESS_TEAM_DOMAIN variable (next step).

Copy your Account ID from the dashboard (Workers & Pages overview, or any zone's overview page).

Step 3: Add repository secrets and variables

In your copy's repository settings, add:

NameKindValue
CLOUDFLARE_API_TOKENSecretThe API token from step 2.
CLOUDFLARE_ACCOUNT_IDSecretYour Cloudflare account ID.
DIFFINERY_DOMAINVariableYour hostname, for example git.example.com. Without it, the deploy targets diffinery.com.
DIFFINERY_ZONEVariable, optionalThe Cloudflare zone that holds the hostname, when it isn't the hostname's last two labels.
MAIL_DOMAINVariable, optionalThe domain email is sent from, when it isn't DIFFINERY_DOMAIN.
ACCESS_TEAM_DOMAINVariable, optionalYour Zero Trust team domain, for example example.cloudflareaccess.com. Needed only when the token can't read it.

The Deploy job runs in an environment named production, so you can also store the secrets as environment secrets there and add required reviewers to that environment.

Step 4: Run the Deploy workflow

Push to main, or start Deploy by hand (workflow_dispatch). The job takes up to 45 minutes; most of it is the first build of the runner image. Here is what each script does, so you can read its output.

scripts/bootstrap.sh

Bootstrap needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, and reads these optional variables:

VariableDefaultUsed for
DIFFINERY_DOMAINdiffinery.comYour hostname: the Worker's route, PUBLIC_URL (https://<DIFFINERY_DOMAIN>), the Access application and the email sender.
DIFFINERY_ZONEThe hostname's last two labelsThe Cloudflare zone the route is added to. Passed to render-config as --zone.
MAIL_DOMAINDIFFINERY_DOMAINThe domain email is sent from (notifications@<MAIL_DOMAIN>) and that the Email Sending check looks at.
ACCESS_TEAM_DOMAINDiscoveredYour Zero Trust team domain, when the token can't read it.

Each section prints a collapsible log group:

  1. D1 database. Looks for a database named diffinery and creates it if missing.
  2. R2 bucket. Creates diffinery-data if missing, then sets its lifecycle rules, which delete short-lived data (such as expired artifacts, old job logs and unfinished uploads) by age and never touch git objects, LFS objects or release assets. The rules are set on every deploy and replace any lifecycle rules you added in the dashboard. If they can't be set, or a rule would still delete git objects, bootstrap stops the deploy. See R2 for the rules.
  3. Queues. Creates diffinery-events, diffinery-events-dlq, diffinery-mail and diffinery-mail-dlq if missing.
  4. Email Sending. Runs scripts/email-sending.sh for MAIL_DOMAIN (default DIFFINERY_DOMAIN). A failure here is only a warning: the deploy continues and email is skipped until sending works.
  5. Cloudflare Access. Finds your team domain (from ACCESS_TEAM_DOMAIN, else from your Zero Trust organization through the API). Then it creates, or updates, a self-hosted Access application named Diffinery account recovery that covers only <domain>/auth/access, with a 15-minute Access session, an HTTP-only cookie and a policy that lets anyone who proves their email through. Diffinery, not Access, decides what that person may do. It also deletes an application named Diffinery machine endpoints left by older versions. If it still has no team domain, it requests https://<domain>/auth/access and reads the team domain from the Access redirect.
  6. Render config and migrate D1. Runs scripts/render-config.mjs with --public-url https://<DIFFINERY_DOMAIN>, --zone <DIFFINERY_ZONE> (only when set) and --mail-from notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>, and then wrangler d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc.

At the end it prints team=<team domain> aud=set and, inside a workflow, writes d1_id, access_team and access_aud_set to the step outputs.

scripts/render-config.mjs

node scripts/render-config.mjs --d1 <database id> [--team <x.cloudflareaccess.com>] [--aud <tag>] \
  [--public-url https://git.example.com] [--zone example.com] [--mail-from notifications@example.com]

It reads apps/diffinery/wrangler.jsonc, drops the env section (the dev environment) and $schema, and changes it as follows:

FlagRequiredEffect on the deployed config
--d1 <database id>YesSets the D1 database_id. Without it the script stops with --d1 <database id> is required.
--team <team domain>NoSets ACCESS_TEAM_DOMAIN.
--aud <tag>NoSets ACCESS_AUD.
--public-url <origin>NoSets PUBLIC_URL to the URL's origin, replaces routes with one route <hostname>/* in the zone from --zone, and sets MAIL_FROM to notifications@<hostname>. The URL must use https:// and have no path, otherwise the script stops with --public-url must be an https origin, like https://git.example.com.
--zone <zone>NoThe zone for the route. Defaults to the hostname's last two labels (git.example.com gives example.com). Only used together with --public-url.
--mail-from <address>NoSets MAIL_FROM, overriding the notifications@<hostname> that --public-url sets.

Without --public-url, the route, PUBLIC_URL and MAIL_FROM stay as they are in wrangler.jsonc. Bootstrap always passes --public-url and --mail-from, so a normal deploy takes them from DIFFINERY_DOMAIN and MAIL_DOMAIN.

For DIFFINERY_DOMAIN=git.example.com, the deployed config contains:

{
  "routes": [{ "pattern": "git.example.com/*", "zone_name": "example.com" }],
  "vars": {
    "PUBLIC_URL": "https://git.example.com",
    "MAIL_FROM": "notifications@git.example.com"
  }
}

The script writes apps/diffinery/wrangler.deploy.jsonc, which every later wrangler command uses. That file is generated on each deploy and ignored by git; don't edit it.

wrangler deploy

pnpm exec wrangler deploy -c wrangler.deploy.jsonc (run in apps/diffinery) deploys the Worker with all its bindings, and builds the runner image from containers/runner/Dockerfile (Ubuntu 24.04 with Node.js 22, git, git-lfs and the runner agent) for all three container classes: RunnerContainer (hosted jobs), SandboxContainer (agent sandboxes) and ImportContainer (the Git transfers of GitHub imports). It also applies Durable Object migrations the account doesn't have yet: tag v1 creates RepoDO, RunnerContainer and SandboxContainer, tag v2 creates ImportContainer, tag v3 creates ProjectDO, and tag v4 creates SchedulerDO.

scripts/ensure-secrets.sh

Creates the Worker's own secrets once and never rotates them:

  • MASTER_KEY: 32 random bytes, base64 (openssl rand -base64 32). It is the root key for encrypting Actions secrets, signing unsubscribe links, and signing the tokens and links that workflow jobs use for caches and artifacts.
  • OIDC_PRIVATE_KEY: a new ECDSA P-256 private key as a JWK, with a key ID like diffinery-2026-10-13. It signs workflow OIDC tokens.

Values are generated on the runner and piped straight into wrangler secret put; they are never printed. The script lists existing secrets first and stops with unexpected output from wrangler secret list if it can't read them, rather than risk overwriting MASTER_KEY, which would make every stored Actions secret unreadable.

Finishing Access on the first deploy

On a brand-new account, bootstrap may not find the team domain before the Worker exists. When its access_team output is empty, the workflow runs scripts/bootstrap.sh once more (which can now read the team domain from the live /auth/access redirect) and redeploys with wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none.

Checks after the deploy

The workflow then checks the live site:

  • /healthz must answer 200 within 5 minutes (30 tries, 10 seconds apart). A new hostname can take a few minutes to get its certificate.
  • /login must be the passkey sign-in page (it looks for "Sign in with a passkey") and its Kumo JavaScript and CSS bundles must load.
  • /auth/access should redirect (302) to https://<team>.cloudflareaccess.com/.... If not, the job only warns.
  • The favicon, web manifest, robots.txt and sitemap.xml must load, and the home page must carry its canonical, Open Graph and structured data tags.
  • Responses must carry an x-d1-bookmark header, which shows that D1 queries run in sessions.

Step 5: Set up email sending

Diffinery sends invitations, sign-in links, security alerts and notifications through Cloudflare Email Sending from MAIL_FROM, which the deploy sets to notifications@<MAIL_DOMAIN> (by default notifications@<DIFFINERY_DOMAIN>, for example notifications@git.example.com). Onboard the sending domain once in the Cloudflare dashboard: Compute → Email Service → Email Sending → Onboard Domain. Cloudflare adds the DNS records it needs: a bounce MX record and an SPF record on cf-bounce.<domain>, a DKIM record and a DMARC record.

scripts/email-sending.sh runs on every deploy (inside bootstrap) and changes nothing. It:

  1. Finds the zone that holds the domain (the domain itself or a parent).
  2. With Email Sending access on the token, checks that the domain is onboarded and compares every DNS record Email Sending asks for with what is published. Each record is printed as ok or missing. An existing DMARC policy of your own counts as ok.
  3. Without that access, it checks the public DNS instead: the cf-bounce.<domain> MX record (pointing at mx.cloudflare.net), the cf-bounce.<domain> SPF record and _dmarc.<domain>.

It always exits successfully, so a missing record never blocks a deploy; it prints a warning instead. Set MAIL_DOMAIN if your sending domain differs from DIFFINERY_DOMAIN: it changes both the sender address and the domain this check looks at.

Once deployed, a site administrator can check delivery in Settings → Email (/settings/admin/email) or with:

curl -sS https://git.example.com/api/v3/diffinery/admin/email -H "Authorization: Bearer dfy_..."

sending: true means the Worker has the Email Sending binding; last_24h counts sent, failed and skipped emails. Click Send me a test email under Settings → Notifications to send yourself one.

Step 6: DNS

Diffinery is served through a Workers route (git.example.com/* in the zone DIFFINERY_ZONE, by default the hostname's last two labels), not a Custom Domain. A route only runs for hostnames whose DNS record is proxied (orange cloud). If the hostname already has a record, turn on its proxy. If it has none, create a proxied record for it; Cloudflare's documentation suggests an AAAA record pointing at 100:: for a hostname that only a Worker serves.

If the smoke test fails with /healthz did not return 200, this record is the first thing to check.

Step 7: Sign in as the first administrator

Diffinery has no passwords and no open sign-up. The first administrator signs in through the email check at /auth/access:

  1. Open https://git.example.com/auth/access.
  2. Cloudflare Access asks for your email and sends you a one-time code. Enter it.
  3. Access sends you back to Diffinery with a signed assertion. Diffinery checks it against ACCESS_TEAM_DOMAIN and ACCESS_AUD, then finds or creates the account for that email. Because your email is in BOOTSTRAP_ADMINS, the new account is active and a site administrator. Your username is taken from the part of the email before @.
  4. You land on Settings → Passkeys and sessions with the message "You're signed in. Add a passkey now so you can sign in directly next time." Add a passkey.

From then on, sign in at /login with your passkey, and invite everyone else from Settings → People and invitations or with diffinery invite. Someone whose email is not in BOOTSTRAP_ADMINS and who has no invite gets an account that waits for an invite ("Waiting for an invite"). They can send an access request, which a site administrator approves under Pending access requests in Settings → People and invitations.

BOOTSTRAP_ADMINS only applies when an account is created, or when an account that is still waiting for an invite signs in through /auth/access. Changing it later does not promote or demote existing accounts.

Step 8: Cloudflare Access for /auth/access

Access guards only /auth/access. Everything else (the web UI, git, the REST API, MCP, runners, the registry) reaches the Worker directly, so Access never gets in the way of tokens or passkeys.

  • Login methods. Access's one-time PIN by email works with no setup. You can add more identity providers under Zero Trust → Settings → Authentication (for example GitHub); Diffinery uses the email address Access reports.
  • Who gets in. The application's policy allows everyone who proves an email address. Diffinery then decides: a known account gets a session, a BOOTSTRAP_ADMINS email becomes an admin, anyone else waits for an invite, and disabled accounts are refused.
  • Settings Diffinery needs. ACCESS_TEAM_DOMAIN (like example.cloudflareaccess.com) and ACCESS_AUD (the application's audience tag). Bootstrap sets both in the deployed config. If either is empty, /auth/access answers "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link." and the sign-in page doesn't offer email recovery.
  • Finding the team domain. In the Zero Trust dashboard, Settings → Custom pages shows it. Set it as the ACCESS_TEAM_DOMAIN repository variable if bootstrap can't find it.

Deploy from your own machine

You can run the same steps without a workflow. From a clone, with the tools listed in What you need:

export CLOUDFLARE_API_TOKEN=...
export CLOUDFLARE_ACCOUNT_ID=...
export DIFFINERY_DOMAIN=git.example.com
# export DIFFINERY_ZONE=example.com                        # if the zone isn't the hostname's last two labels
# export MAIL_DOMAIN=example.com                           # to send email from another domain
# export ACCESS_TEAM_DOMAIN=example.cloudflareaccess.com   # if the token can't read it

pnpm install --frozen-lockfile
pnpm -r --if-present build
scripts/bootstrap.sh
(cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc)
scripts/ensure-secrets.sh

If bootstrap ended with team=?, run scripts/bootstrap.sh again now that the Worker is live, then deploy once more with --containers-rollout none.

Updating

To update, bring your copy up to date with upstream and run Deploy again (or push to main if your workflow deploys on push). Each run:

  • Creates nothing that already exists and keeps your D1 data, R2 data, secrets and Access application.
  • Applies new D1 migrations before the new Worker goes live. For example, 0010_imports.sql adds the tables for GitHub imports, linked GitHub accounts and organization variables.
  • Rebuilds the documentation from the Markdown files in your copy, so /docs matches the code you deploy.
  • Rolls out a new runner image. The first sandbox started on a machine after a deploy pulls the new image first, so it can take a few minutes to start; Diffinery waits up to 3 minutes for it. Running hosted jobs and imports keep their containers until they finish, and the next ones start on the new image. A sandbox up for more than an hour is replaced.

Keep MASTER_KEY safe and never change it: it decrypts every stored Actions secret (repository, organization and environment secrets), unsubscribe links in emails already sent are signed with it, and so are the cache and artifact tokens of jobs that are running. If you delete OIDC_PRIVATE_KEY, the next deploy generates a new key with a new key ID; systems that trust your OIDC tokens fetch the new public key from /.well-known/jwks.

Local development

You can run Diffinery on your machine with wrangler dev, which simulates D1, R2, Queues, Durable Objects and Workflows locally.

pnpm install
pnpm -r build
pnpm --filter @diffinery/app db:migrate:local
pnpm dev

pnpm dev runs wrangler dev -e dev in apps/diffinery and serves http://localhost:8787. The dev environment differs from production:

Productiondev environment
PUBLIC_URLhttps://git.example.comhttp://localhost:8787
Sign-inPasskeys, tokens, /auth/accessAny request without credentials acts as dev@diffinery.local (DEV_AUTH), who is a site administrator. Tokens still work.
D1, R2, queues, Workflowsdiffinery, diffinery-data, diffinery-events, diffinery-mail, diffinery-run, diffinery-importdiffinery-dev, diffinery-data-dev, diffinery-events-dev, diffinery-mail-dev, diffinery-run-dev, diffinery-import-dev, all local
ContainersHosted runners, sandboxes and import transfersNone. runs-on: ubuntu-latest jobs and sandboxes can't start; use a self-hosted runner. A GitHub import waits for you to run its Git transfer by hand (IMPORT_GIT_AGENT=external; see The dev environment).
CronEvery 5 minutesNone
EmailSentCaptured by Miniflare, not delivered. GET /api/v3/diffinery/emails shows each full message.
MASTER_KEYSet by ensure-secrets.shNot set: storing Actions secrets fails, and jobs can't use caches or artifacts, until you add one.

To store secrets locally, put a key in apps/diffinery/.dev.vars (ignored by git):

echo "MASTER_KEY=$(openssl rand -base64 32)" > apps/diffinery/.dev.vars

To run workflow jobs locally, use runs-on: [self-hosted, linux] in the workflow and start a runner with the CLI against your local instance. It needs a token (create one without credentials, as the dev user) and the runner agent built by pnpm -r build:

export DIFFINERY_URL=http://localhost:8787
export DIFFINERY_TOKEN=$(curl -sS -X POST http://localhost:8787/api/v3/diffinery/tokens \
  -H "content-type: application/json" --data '{"name":"local","scopes":["admin"]}' | jq -r .token)
diffinery runner -R dev/website --labels linux --agent containers/runner/dist/agent.mjs

Tests

pnpm -r build && pnpm -r typecheck && pnpm -r test
pnpm --filter @diffinery/app exec playwright install --with-deps chromium
pnpm --filter @diffinery/app e2e

The end-to-end tests start a real wrangler dev with fresh local state, push and clone with the real git, run workflow jobs with the real runner agent as a self-hosted runner, and drive passkey sign-in in Chromium with a virtual authenticator. They need git installed. Set E2E_RUNNER_IMAGE to a built runner image to run jobs inside Docker instead, and E2E_KEEP=1 to keep the temporary directory for debugging.

Troubleshooting

MessageWhereWhat to do
set CLOUDFLARE_API_TOKEN or set CLOUDFLARE_ACCOUNT_IDbootstrap, email checkAdd the secrets (step 3), or export them for a manual deploy.
could not create D1 database (does the token have D1: Edit?)bootstrapAdd D1 Edit to the token.
could not create R2 bucket (R2 enabled? token has R2: Edit?)bootstrapEnable R2 on the account (dashboard, R2) and add R2 Edit to the token.
could not set the R2 lifecycle rules (token has R2: Edit?): ...bootstrapAdd R2 Edit to the token. The text after the colon is Cloudflare's error. The deploy stops here, because the bucket's rules weren't confirmed.
an R2 lifecycle rule still deletes objects under repos/ (git objects, LFS); refusing to continuebootstrapThe rules were set, but reading them back still shows a rule that would delete git or LFS objects. Check the lifecycle rules of the diffinery-data bucket in the Cloudflare dashboard, remove the rule that covers repos/ or the whole bucket, and rerun the deploy.
could not create queue diffinery-events (token has Queues: Edit?)bootstrapAdd Queues Edit to the token.
Could not read Access applications (token needs Access: Apps and Policies Edit). Passkeys work; email recovery stays off.bootstrapAdd Access: Apps and Policies Edit. Until then nobody can use /auth/access, so the first administrator can't sign in.
Could not create the Access application; email recovery stays off.bootstrapCheck that the account has a Zero Trust organization and that the token has Access: Apps and Policies Edit.
Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it).bootstrapSet the variable and deploy again.
Email Sending check failed; email may not be delivered.bootstrapRead the email check's output above it.
Could not find the Cloudflare zone for git.example.com (the token needs Zone Read on it).email checkAdd Zone Read for the zone, or ignore it if you don't send email yet.
git.example.com is not onboarded to Email Sending, so no email will be delivered.email checkOnboard the domain (step 5).
Some Email Sending DNS records for git.example.com are missing, so mail may not be delivered.email checkFinish onboarding in the dashboard; the lines marked missing name the records.
unexpected output from wrangler secret listensure-secretsWrangler couldn't list the Worker's secrets (usually a token permission or a failed deploy). Fix that and rerun; nothing was overwritten.
--public-url must be an https origin, like https://git.example.comrender-configDIFFINERY_DOMAIN (or the --public-url you passed) isn't a bare hostname. Set it to a hostname such as git.example.com, without a scheme or a path.
--d1 <database id> is requiredrender-configYou ran the script by hand without --d1. Pass the D1 database ID that bootstrap prints.
https://.../healthz did not return 200 ... Check that the ... DNS record is proxied (orange cloud).smoke testCheck the DNS record (step 6) and the route in wrangler.deploy.jsonc: its pattern must be your hostname and its zone_name the zone that holds it (set DIFFINERY_ZONE if not). A new certificate can take a few minutes; rerun the workflow.
/login is not the Diffinery sign-in pagesmoke testAnother service answers on the hostname. Check DIFFINERY_DOMAIN, DIFFINERY_ZONE and the route in wrangler.deploy.jsonc.
/auth/access is not behind Cloudflare Access yet, so email recovery and first admin sign-in will not worksmoke test (warning)Check the Access step's output; the application must cover <hostname>/auth/access.
D1 queries don't run in a session (no x-d1-bookmark header)smoke testThe request to /sitemap.xml failed or the deployed Worker isn't the one you just built. Check that wrangler deploy succeeded and look at the Worker's logs.
"Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link."/auth/accessACCESS_TEAM_DOMAIN or ACCESS_AUD is empty in the deployed config. Fix the Access step and redeploy.
"Cloudflare Access sign-in was rejected: audience mismatch" (or issuer mismatch)/auth/accessThe deployed ACCESS_AUD or ACCESS_TEAM_DOMAIN doesn't match the Access application. Rerun Deploy so bootstrap writes the current values.
"Waiting for an invite" after the email checkWeb UIYour email isn't in BOOTSTRAP_ADMINS and nobody invited you. Fix the list in wrangler.jsonc, deploy, and sign in through /auth/access again.
docs: N problem(s): followed by a listBuildA Markdown page under apps/diffinery/docs has missing or overlong frontmatter, uses an H1, or isn't listed in docs/nav.json (or the other way round). Fix the listed pages; each line names the file.
could not store the secret: ... (is MASTER_KEY configured?)Repository settingsMASTER_KEY is missing. Run scripts/ensure-secrets.sh, or add it to .dev.vars locally.
The cache service is not available to this job; skipping. or The artifact service is not available to this job.Workflow job logsMASTER_KEY is missing, so jobs get no cache and artifact token. Run scripts/ensure-secrets.sh, or add it to .dev.vars locally.
OIDC is not configured on this Diffinery instanceWorkflow jobs with id-token: writeOIDC_PRIVATE_KEY is missing. Run scripts/ensure-secrets.sh.
no import container is configured (IMPORTER binding)A GitHub import's errorThe deployed config has no IMPORTER Durable Object binding or ImportContainer container. Restore them from wrangler.jsonc and deploy again.
the Git transfer did not finish within 6 hoursA GitHub import's errorCopying the repository's Git data took longer than the 6-hour limit. Lower IMPORT_MAX_REPO_KB so that repositories this large are refused before they start, and move them another way (for example a mirror push in several steps). See Configuration.