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.

253 lines · 14.1 KB · executableRaw History
1#!/usr/bin/env bash
2# Deploy Diffinery to your own Cloudflare account and domain from your own machine: the first
3# deploy and every update after it. It runs the same steps as .github/workflows/deploy.yml.
4#
5# scripts/setup.sh # asks for whatever isn't in the environment, then deploys
6# scripts/setup.sh --dry-run # checks everything and builds the Worker, but changes nothing
7# pnpm setup:cloudflare # the same as scripts/setup.sh
8#
9# Settings, from the environment or asked for (in a terminal):
10# CLOUDFLARE_API_TOKEN API token with the permissions in docs/self-hosting/deploy.md (asked without echo)
11# CLOUDFLARE_ACCOUNT_ID the Cloudflare account to deploy to
12# DIFFINERY_DOMAIN your instance's hostname, e.g. git.example.com
13# BOOTSTRAP_ADMINS comma-separated emails that become site administrators on first sign-in;
14# empty keeps the list the deployed Worker has (scripts/bootstrap-admins.sh)
15# Optional, from the environment only:
16# DIFFINERY_ZONE the Cloudflare zone holding DIFFINERY_DOMAIN (default: its last two labels)
17# MAIL_DOMAIN send email from notifications@<MAIL_DOMAIN> (default DIFFINERY_DOMAIN)
18# ACCESS_TEAM_DOMAIN your Zero Trust team domain, e.g. example.cloudflareaccess.com, when the token can't read it
19# ROLLOUT_WAIT_MINUTES how long to wait for the container rollout (default 10)
20#
21# Every step is idempotent: it creates only what is missing and keeps your data and secrets, so run
22# it again to update. Secrets are never printed. Needs Node.js 22+, pnpm, jq, curl, openssl and Docker.
23set -euo pipefail
24
25ROOT="$(cd "$(dirname "$0")/.." && pwd)"
26APP="$ROOT/apps/diffinery"
27CF_API="https://api.cloudflare.com/client/v4"
28PLACEHOLDER_D1="00000000-0000-0000-0000-000000000000"
29DRY_RUN=0
30ASK=1
31
32usage() {
33 cat <<'EOF'
34Usage: scripts/setup.sh [--dry-run] [--yes] [--help]
35
36Deploys Diffinery to your Cloudflare account and domain, or updates a deployment: checks the
37tools and your API token, then builds, creates any missing Cloudflare resources (scripts/bootstrap.sh),
38deploys the Worker and runner image, sets the Worker's secrets and checks the live site.
39
40 --dry-run Check the tools, the token and the zone (read-only, when a token is given), build,
41 render apps/diffinery/wrangler.deploy.jsonc with a placeholder D1 id and run
42 `wrangler deploy --dry-run` on it. Creates, changes and deploys nothing.
43 --yes, -y Don't ask: take every setting from the environment and fail if one is missing.
44 --help, -h Show this help.
45
46Settings (environment variables; asked for in a terminal when missing):
47 CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, DIFFINERY_DOMAIN (e.g. git.example.com),
48 BOOTSTRAP_ADMINS (comma-separated emails of the first site administrators; empty keeps the
49 deployed Worker's list). Optional: DIFFINERY_ZONE, MAIL_DOMAIN, ACCESS_TEAM_DOMAIN.
50
51Docs: apps/diffinery/docs/self-hosting/deploy.md (served at /docs/self-hosting/deploy).
52EOF
53}
54
55for arg in "$@"; do
56 case "$arg" in
57 --dry-run) DRY_RUN=1 ;;
58 --yes | -y) ASK=0 ;;
59 --help | -h) usage; exit 0 ;;
60 *) echo "unknown option: $arg (see scripts/setup.sh --help)" >&2; exit 2 ;;
61 esac
62done
63[[ -t 0 ]] || ASK=0
64
65step() { printf '\n==> %s\n' "$*"; }
66info() { printf ' %s\n' "$*"; }
67warn() { printf 'warning: %s\n' "$*" >&2; }
68die() { printf 'error: %s\n' "$*" >&2; exit 1; }
69
70# ------------------------------------------------------------------ prerequisites
71step "Checking tools"
72missing=()
73for tool in node pnpm jq curl openssl; do command -v "$tool" >/dev/null 2>&1 || missing+=("$tool"); done
74((${#missing[@]} == 0)) || die "missing: ${missing[*]}. Install Node.js 22+, pnpm 10, jq, curl, openssl and Docker (see the deploy docs)."
75node_major="$(node -p 'process.versions.node.split(".")[0]')"
76((node_major >= 22)) || die "Node.js 22 or later is needed (this is $(node --version))"
77info "node $(node --version), pnpm $(pnpm --version), $(jq --version), curl, openssl"
78if ! command -v docker >/dev/null 2>&1; then
79 ((DRY_RUN)) || die "docker is missing: the runner container image is built with Docker. Install it and start the daemon."
80 warn "docker is missing; a real deploy needs it to build the runner image"
81elif ! docker info >/dev/null 2>&1; then
82 ((DRY_RUN)) || die "docker is installed but its daemon isn't running (docker info failed). Start it and run this again."
83 warn "the Docker daemon isn't running; a real deploy needs it to build the runner image"
84else
85 info "docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo '(running)')"
86fi
87
88# ------------------------------------------------------------------ settings
89# ask VAR "Prompt" [secret]: keeps a value from the environment, else asks (in a terminal).
90ask() {
91 local var="$1" prompt="$2" secret="${3:-}" value
92 value="${!var:-}"
93 if [[ -z "$value" && $ASK == 1 ]]; then
94 if [[ -n "$secret" ]]; then
95 read -rsp "$prompt: " value; echo
96 else
97 read -rp "$prompt: " value
98 fi
99 fi
100 printf -v "$var" '%s' "$value"
101 export "${var?}"
102}
103
104step "Settings"
105ask CLOUDFLARE_API_TOKEN "Cloudflare API token (input hidden)" secret
106ask CLOUDFLARE_ACCOUNT_ID "Cloudflare account ID"
107ask DIFFINERY_DOMAIN "Hostname for your instance (e.g. git.example.com)"
108if [[ -z "${BOOTSTRAP_ADMINS:-}" && $ASK == 1 ]]; then
109 read -rp "Emails of the first site administrators, comma-separated (empty keeps the deployed list): " BOOTSTRAP_ADMINS
110fi
111export BOOTSTRAP_ADMINS="${BOOTSTRAP_ADMINS:-}"
112
113DIFFINERY_DOMAIN="$(tr '[:upper:]' '[:lower:]' <<<"$DIFFINERY_DOMAIN")"
114[[ -n "$DIFFINERY_DOMAIN" ]] || die "set DIFFINERY_DOMAIN to your instance's hostname, for example git.example.com"
115[[ "$DIFFINERY_DOMAIN" =~ ^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}$ ]] || die "DIFFINERY_DOMAIN must be a bare hostname such as git.example.com, without https:// or a path (got \"$DIFFINERY_DOMAIN\")"
116ZONE="${DIFFINERY_ZONE:-$(awk -F. '{print $(NF-1)"."$NF}' <<<"$DIFFINERY_DOMAIN")}"
117[[ "$DIFFINERY_DOMAIN" == "$ZONE" || "$DIFFINERY_DOMAIN" == *".$ZONE" ]] || die "DIFFINERY_ZONE ($ZONE) doesn't hold $DIFFINERY_DOMAIN"
118MAIL_FROM="notifications@${MAIL_DOMAIN:-$DIFFINERY_DOMAIN}"
119if [[ -n "$BOOTSTRAP_ADMINS" ]]; then
120 IFS=, read -ra entries <<<"$BOOTSTRAP_ADMINS"
121 for e in "${entries[@]}"; do
122 e="$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$e")"
123 [[ -z "$e" || "$e" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]] || die "BOOTSTRAP_ADMINS takes comma-separated email addresses; \"$e\" isn't one"
124 done
125fi
126export DIFFINERY_DOMAIN
127[[ -z "${DIFFINERY_ZONE:-}" ]] || export DIFFINERY_ZONE
128info "hostname: https://$DIFFINERY_DOMAIN (route $DIFFINERY_DOMAIN/* in zone $ZONE)"
129info "email: $MAIL_FROM"
130info "admins: $(if [[ -n "$BOOTSTRAP_ADMINS" ]]; then tr ',' '\n' <<<"$BOOTSTRAP_ADMINS" | grep -c '[^[:space:]]' | sed 's/$/ address(es) from BOOTSTRAP_ADMINS/'; else echo "keep the deployed Worker's list (none on a new instance)"; fi)"
131if ((DRY_RUN)); then info "mode: dry run (changes nothing)"; else info "mode: deploy"; fi
132
133# ------------------------------------------------------------------ token and zone
134cf_get() { curl -sS --retry 3 --retry-delay 2 "$CF_API$1" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"; }
135errors_of() { jq -r '[.errors[]? | "\(.code): \(.message)"] | join("; ")' <<<"$1" 2>/dev/null || echo "no answer"; }
136
137if [[ -z "$CLOUDFLARE_API_TOKEN" || -z "$CLOUDFLARE_ACCOUNT_ID" ]]; then
138 ((DRY_RUN)) || die "set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (Step 2 of the deploy docs says which permissions the token needs)"
139 step "Cloudflare token"
140 warn "no CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID: the dry run skips the token and zone checks"
141else
142 [[ "$CLOUDFLARE_ACCOUNT_ID" =~ ^[0-9a-f]{32}$ ]] || die "CLOUDFLARE_ACCOUNT_ID should be 32 hex characters (the dashboard's Account ID), not \"$CLOUDFLARE_ACCOUNT_ID\""
143 step "Cloudflare token"
144 # User tokens verify at /user/tokens/verify, account-owned tokens at /accounts/{id}/tokens/verify.
145 verify="$(cf_get /user/tokens/verify || true)"
146 if ! jq -e '.success == true and .result.status == "active"' >/dev/null 2>&1 <<<"$verify"; then
147 verify="$(cf_get "/accounts/$CLOUDFLARE_ACCOUNT_ID/tokens/verify" || true)"
148 fi
149 jq -e '.success == true and .result.status == "active"' >/dev/null 2>&1 <<<"$verify" || die "Cloudflare doesn't accept the API token: $(errors_of "$verify")"
150 info "the token is valid and active"
151 zones="$(cf_get "/zones?name=$ZONE&account.id=$CLOUDFLARE_ACCOUNT_ID" || true)"
152 jq -e '.success == true' >/dev/null 2>&1 <<<"$zones" || die "couldn't look up the zone $ZONE: $(errors_of "$zones")"
153 zone_status="$(jq -r '.result[0].status // empty' <<<"$zones")"
154 [[ -n "$zone_status" ]] || die "the zone $ZONE isn't in account $CLOUDFLARE_ACCOUNT_ID, or the token can't see it. Add the domain to the account, give the token Zone → Zone → Read on it, or set DIFFINERY_ZONE if the zone that holds $DIFFINERY_DOMAIN is another one."
155 [[ "$zone_status" == active ]] || warn "the zone $ZONE is $zone_status, not active: the route serves nothing until its nameservers point at Cloudflare"
156 info "zone $ZONE is in the account ($zone_status)"
157fi
158
159cd "$ROOT"
160
161# ------------------------------------------------------------------ build
162step "Installing and building"
163pnpm install --frozen-lockfile
164pnpm -r --if-present build
165
166if ((DRY_RUN)); then
167 # ---------------------------------------------------------------- dry run
168 step "Bootstrap administrators"
169 # Reads the deployed Worker's list when there are credentials (a read, never a write).
170 ADMINS="$("$ROOT/scripts/bootstrap-admins.sh")"
171
172 step "Rendering the deploy config (placeholder D1 id) and bundling the Worker"
173 # A placeholder runner image: the dry run builds no container image (that needs Docker and the registry).
174 RUNNER_IMAGE="registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID:-00000000000000000000000000000000}/diffinery-runner:dry-run" \
175 node "$ROOT/scripts/render-config.mjs" --d1 "$PLACEHOLDER_D1" ${ACCESS_TEAM_DOMAIN:+--team "$ACCESS_TEAM_DOMAIN"} \
176 --public-url "https://$DIFFINERY_DOMAIN" ${DIFFINERY_ZONE:+--zone "$DIFFINERY_ZONE"} --mail-from "$MAIL_FROM" --admins "$ADMINS"
177 OUT="$(mktemp -d "${TMPDIR:-/tmp}/diffinery-dry-run.XXXXXX")"
178 (cd "$APP" && pnpm exec wrangler deploy --dry-run -c wrangler.deploy.jsonc --outdir "$OUT")
179
180 CFG="$(tail -n +2 "$APP/wrangler.deploy.jsonc")"
181 step "Dry run summary"
182 info "route: $(jq -r '.routes | map("\(.pattern) (zone \(.zone_name))") | join(", ")' <<<"$CFG")"
183 info "PUBLIC_URL: $(jq -r .vars.PUBLIC_URL <<<"$CFG")"
184 info "MAIL_FROM: $(jq -r .vars.MAIL_FROM <<<"$CFG")"
185 info "BOOTSTRAP_ADMINS: $(jq -r '.vars.BOOTSTRAP_ADMINS | split(",") | map(select(length > 0)) | length' <<<"$CFG") address(es)"
186 info "bundle: $OUT"
187 if [[ "$DIFFINERY_DOMAIN" != diffinery.com ]] && grep -q 'diffinery\.com' <<<"$CFG"; then
188 warn "the rendered config still mentions diffinery.com: $(grep -o '"[^"]*diffinery\.com[^"]*"' <<<"$CFG" | sort -u | tr '\n' ' ')"
189 fi
190 echo
191 echo "Dry run passed: apps/diffinery/wrangler.deploy.jsonc is valid and the Worker bundles. Nothing was"
192 echo "created or deployed. Run scripts/setup.sh without --dry-run to deploy to https://$DIFFINERY_DOMAIN."
193 exit 0
194fi
195
196# ------------------------------------------------------------------ deploy
197wrangler() { (cd "$APP" && pnpm exec wrangler "$@"); }
198OUTPUTS="$(mktemp "${TMPDIR:-/tmp}/diffinery-setup.XXXXXX")"
199trap 'rm -f "$OUTPUTS"' EXIT
200
201step "Runner image (built only when the runner changed)"
202image_log="$(mktemp "${TMPDIR:-/tmp}/diffinery-image.XXXXXX")"
203env -u GITHUB_ENV "$ROOT/scripts/runner-image.sh" | tee "$image_log"
204RUNNER_IMAGE="$(tail -n 1 "$image_log")"
205rm -f "$image_log"
206[[ "$RUNNER_IMAGE" == registry.cloudflare.com/* ]] || die "scripts/runner-image.sh didn't print the runner image"
207export RUNNER_IMAGE
208
209step "Creating or updating Cloudflare resources (scripts/bootstrap.sh)"
210GITHUB_OUTPUT="$OUTPUTS" "$ROOT/scripts/bootstrap.sh"
211ACCESS_TEAM="$(sed -n 's/^access_team=//p' "$OUTPUTS" | tail -n 1)"
212
213step "Deploying the Worker and runner image"
214wrangler deploy -c wrangler.deploy.jsonc
215
216step "Waiting for new containers to get the new image"
217"$ROOT/scripts/wait-container-rollout.sh"
218
219step "Worker secrets (generated on the first deploy; never printed)"
220"$ROOT/scripts/ensure-secrets.sh"
221
222if [[ -z "$ACCESS_TEAM" ]]; then
223 step "Finishing the Access setup (first deploy)"
224 : >"$OUTPUTS"
225 GITHUB_OUTPUT="$OUTPUTS" "$ROOT/scripts/bootstrap.sh"
226 wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none
227 ACCESS_TEAM="$(sed -n 's/^access_team=//p' "$OUTPUTS" | tail -n 1)"
228fi
229
230# ------------------------------------------------------------------ checks
231step "Checking https://$DIFFINERY_DOMAIN"
232code=""
233for _ in $(seq 1 30); do
234 code="$(curl -s -o /dev/null -w '%{http_code}' "https://$DIFFINERY_DOMAIN/healthz" || true)"
235 [[ "$code" == 200 ]] && break
236 sleep 10
237done
238[[ "$code" == 200 ]] || die "https://$DIFFINERY_DOMAIN/healthz did not return 200 (last: ${code:-no answer}). Check that the $DIFFINERY_DOMAIN DNS record is proxied (orange cloud); a new certificate can take a few minutes, so run this again."
239info "/healthz -> 200"
240curl -sS "https://$DIFFINERY_DOMAIN/login" | grep -q "Sign in with a passkey" || die "/login is not the Diffinery sign-in page: check DIFFINERY_DOMAIN, DIFFINERY_ZONE and the route in apps/diffinery/wrangler.deploy.jsonc"
241info "/login serves the passkey sign-in page"
242loc="$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' "https://$DIFFINERY_DOMAIN/auth/access" || true)"
243if [[ "$loc" == 302\ https://*.cloudflareaccess.com/* ]]; then
244 info "/auth/access is behind Cloudflare Access"
245else
246 warn "/auth/access is not behind Cloudflare Access yet ($loc), so email recovery and the first admin sign-in won't work. Set ACCESS_TEAM_DOMAIN and run this again."
247fi
248
249echo
250echo "Diffinery is live at https://$DIFFINERY_DOMAIN."
251echo "Sign in as the first administrator at https://$DIFFINERY_DOMAIN/auth/access with an email in BOOTSTRAP_ADMINS,"
252echo "then add a passkey. Run scripts/setup.sh again at any time to update."
253[[ -n "$ACCESS_TEAM" ]] || warn "the Access team domain is still unknown; set ACCESS_TEAM_DOMAIN (Zero Trust → Settings → Custom pages shows it) and run this again"