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.

152 lines · 8.8 KB · executableRaw History
1#!/usr/bin/env bash
2# Idempotent production setup for Diffinery on a Cloudflare account.
3#
4# Creates (only when missing): the D1 database, R2 bucket, event and mail queues, the
5# Email Sending setup for the domain (scripts/email-sending.sh) and the
6# Cloudflare Access application for /auth/access, the email-based account
7# recovery path. Everyday sign-in uses passkeys; git, the API, MCP and runners
8# use Diffinery tokens.
9# Then renders apps/diffinery/wrangler.deploy.jsonc and applies D1 migrations.
10#
11# Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl.
12# Optional: DIFFINERY_DOMAIN (default diffinery.com; the Worker's route, PUBLIC_URL
13# and sender follow it), DIFFINERY_ZONE (the Cloudflare zone holding that host,
14# default its last two labels), MAIL_DOMAIN (default DIFFINERY_DOMAIN),
15# ACCESS_TEAM_DOMAIN (e.g. codician.cloudflareaccess.com) when the token cannot read it.
16set -euo pipefail
17
18: "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}"
19: "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}"
20DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}"
21ROOT="$(cd "$(dirname "$0")/.." && pwd)"
22APP="$ROOT/apps/diffinery"
23WRANGLER=(pnpm --dir "$APP" exec wrangler)
24API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID"
25
26cf() { # cf METHOD PATH [JSON]
27 local method="$1" path="$2" body="${3:-}"
28 if [[ -n "$body" ]]; then
29 curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" --data "$body"
30 else
31 curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
32 fi
33}
34log() { echo "::group::$*"; }
35endlog() { echo "::endgroup::"; }
36warn() { echo "::warning::$*" >&2; }
37
38# ------------------------------------------------------------------ D1
39log "D1 database"
40D1_ID="$(cf GET "/d1/database?name=diffinery&per_page=100" | jq -r '.result[]? | select(.name=="diffinery") | .uuid' | head -n1)"
41if [[ -z "$D1_ID" ]]; then
42 D1_ID="$(cf POST /d1/database '{"name":"diffinery"}' | jq -r '.result.uuid // empty')"
43 [[ -n "$D1_ID" ]] || { echo "could not create D1 database (does the token have D1: Edit?)" >&2; exit 1; }
44 echo "created D1 database $D1_ID"
45else
46 echo "D1 database exists: $D1_ID"
47fi
48echo "read replication: $(cf GET "/d1/database/$D1_ID" | jq -r '.result.read_replication.mode // "unknown"') (turn it on with the 'D1 read replication' workflow)"
49endlog
50
51# ------------------------------------------------------------------ R2
52log "R2 bucket"
53if cf GET "/r2/buckets/diffinery-data" | jq -e '.success' >/dev/null; then
54 echo "R2 bucket exists"
55else
56 cf POST /r2/buckets '{"name":"diffinery-data"}' | jq -e '.success' >/dev/null || { echo "could not create R2 bucket (R2 enabled? token has R2: Edit?)" >&2; exit 1; }
57 echo "created R2 bucket diffinery-data"
58fi
59# Lifecycle rules (layout: apps/diffinery/src/lib/storage-keys.ts). Only transient prefixes
60# age out; repos/ (git objects, LFS, release assets), registry/ and actions-cache/ (evicted
61# by Diffinery) have no age rule. Multipart uploads left unfinished are aborted after a day.
62LIFECYCLE='{"rules":[
63 {"id":"abort-multipart-1d","enabled":true,"conditions":{"prefix":""},"abortMultipartUploadsTransition":{"condition":{"type":"Age","maxAge":86400}}},
64 {"id":"artifacts-90d","enabled":true,"conditions":{"prefix":"artifacts/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":7776000}}},
65 {"id":"logs-90d","enabled":true,"conditions":{"prefix":"logs/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":7776000}}},
66 {"id":"actions-staging-1d","enabled":true,"conditions":{"prefix":"actions-staging/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":86400}}},
67 {"id":"archives-30d","enabled":true,"conditions":{"prefix":"archives/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":2592000}}},
68 {"id":"events-400d","enabled":true,"conditions":{"prefix":"events/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":34560000}}}
69]}'
70# Applied on every deploy; a failure stops it, since a wrong rule can delete data.
71RESULT="$(cf PUT "/r2/buckets/diffinery-data/lifecycle" "$LIFECYCLE")"
72if ! jq -e '.success' >/dev/null <<<"$RESULT"; then
73 echo "could not set the R2 lifecycle rules (token has R2: Edit?): $(jq -c '.errors // .' <<<"$RESULT" 2>/dev/null || echo "$RESULT")" >&2
74 exit 1
75fi
76CURRENT="$(cf GET "/r2/buckets/diffinery-data/lifecycle")"
77if jq -e '[.result.rules[]? | select(.deleteObjectsTransition != null and ((.conditions.prefix // "") | startswith("repos/") or . == ""))] | length > 0' >/dev/null <<<"$CURRENT"; then
78 echo "an R2 lifecycle rule still deletes objects under repos/ (git objects, LFS); refusing to continue" >&2
79 exit 1
80fi
81echo "R2 lifecycle rules set"
82endlog
83
84# ------------------------------------------------------------------ Queues
85log "Queues"
86EXISTING_QUEUES="$(cf GET "/queues?per_page=100" | jq -r '.result[]?.queue_name')"
87for q in diffinery-events diffinery-events-dlq diffinery-mail diffinery-mail-dlq; do
88 if grep -qx "$q" <<<"$EXISTING_QUEUES"; then
89 echo "queue $q exists"
90 else
91 cf POST /queues "{\"queue_name\":\"$q\"}" | jq -e '.success' >/dev/null || { echo "could not create queue $q (token has Queues: Edit?)" >&2; exit 1; }
92 echo "created queue $q"
93 fi
94done
95endlog
96
97# ------------------------------------------------------------------ Email Sending
98# notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>; warns (and email is skipped) until the domain is onboarded.
99DIFFINERY_DOMAIN="$DOMAIN" "$ROOT/scripts/email-sending.sh" || warn "Email Sending check failed; email may not be delivered."
100
101# ------------------------------------------------------------------ Access
102log "Cloudflare Access"
103TEAM="${ACCESS_TEAM_DOMAIN:-}"
104if [[ -z "$TEAM" ]]; then
105 TEAM="$(cf GET /access/organizations | jq -r '.result.auth_domain // empty' || true)"
106fi
107APPS="$(cf GET "/access/apps?per_page=100" || echo '{}')"
108AUD=""
109if ! jq -e '.success' >/dev/null <<<"$APPS"; then
110 warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Passkeys work; email recovery stays off."
111else
112 # People sign in with passkeys. Access guards only /auth/access, the email-based
113 # recovery path, so everything else (web, git, API, MCP, runners) reaches the Worker directly.
114 WEB_BODY="$(jq -nc --arg d "$DOMAIN" '{
115 name: "Diffinery account recovery", type: "self_hosted", domain: ($d + "/auth/access"),
116 destinations: [{type: "public", uri: ($d + "/auth/access")}],
117 session_duration: "15m", app_launcher_visible: false, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax"
118 }')"
119 POLICY='[{"name": "Anyone who proves their email (Diffinery decides access)", "decision": "allow", "include": [{"everyone": {}}], "precedence": 1}]'
120 WEB_ID="$(jq -r '.result[] | select(.name=="Diffinery account recovery" or .name=="Diffinery") | .id' <<<"$APPS" | head -n1)"
121 if [[ -z "$WEB_ID" ]]; then
122 WEB_APP="$(cf POST /access/apps "$(jq -c --argjson p "$POLICY" '. + {policies: $p}' <<<"$WEB_BODY")" | jq -c '.result // empty')"
123 [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN/auth/access" || warn "Could not create the Access application; email recovery stays off."
124 else
125 WEB_APP="$(cf PUT "/access/apps/$WEB_ID" "$WEB_BODY" | jq -c '.result // empty')"
126 [[ -n "$WEB_APP" ]] && echo "Access app covers $DOMAIN/auth/access" || warn "Could not update the Access application."
127 fi
128 AUD="$(jq -r '.aud // empty' <<<"${WEB_APP:-null}")"
129
130 # Earlier versions put Access in front of the whole site and needed a bypass app; remove it.
131 BYPASS_ID="$(jq -r '.result[] | select(.name=="Diffinery machine endpoints") | .id' <<<"$APPS" | head -n1)"
132 if [[ -n "$BYPASS_ID" ]]; then
133 cf DELETE "/access/apps/$BYPASS_ID" | jq -e '.success' >/dev/null && echo "removed the old Access bypass app" || warn "Could not remove the old Access bypass application."
134 fi
135fi
136if [[ -z "$TEAM" ]]; then
137 # Fall back to the sign-in redirect, which names the team domain.
138 TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/auth/access" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)"
139fi
140[[ -n "$TEAM" ]] || warn "Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it)."
141echo "team=${TEAM:-?} aud=${AUD:+set}"
142endlog
143
144# ------------------------------------------------------------------ config + migrations
145log "Render config and migrate D1"
146node "$ROOT/scripts/render-config.mjs" --d1 "$D1_ID" ${TEAM:+--team "$TEAM"} ${AUD:+--aud "$AUD"} --public-url "https://$DOMAIN" ${DIFFINERY_ZONE:+--zone "$DIFFINERY_ZONE"} --mail-from "notifications@${MAIL_DOMAIN:-$DOMAIN}"
147"${WRANGLER[@]}" d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc
148endlog
149
150if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
151 { echo "d1_id=$D1_ID"; echo "access_team=$TEAM"; echo "access_aud_set=${AUD:+true}"; } >>"$GITHUB_OUTPUT"
152fi