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.

125 lines · 6.0 KBRaw History
1name: Deploy
2
3# Deploys Diffinery from main. Uses the repository secrets
4# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, and these optional repository
5# variables (the same ones scripts/bootstrap.sh reads):
6# DIFFINERY_DOMAIN where it is served (default diffinery.com)
7# DIFFINERY_ZONE the Cloudflare zone for the route (default: the domain's last two labels)
8# MAIL_DOMAIN sends from notifications@<MAIL_DOMAIN> (default DIFFINERY_DOMAIN)
9# ACCESS_TEAM_DOMAIN e.g. codician.cloudflareaccess.com
10#
11# It deploys a main commit only once CI (typecheck, unit tests and the Worker bundle) has
12# passed on it, so it doesn't run those itself. End-to-end tests run alongside the deploy.
13on:
14 workflow_run:
15 workflows: [CI]
16 types: [completed]
17 branches: [main]
18 workflow_dispatch:
19
20permissions:
21 contents: read
22
23concurrency:
24 group: deploy-production
25 cancel-in-progress: false
26
27jobs:
28 deploy:
29 name: Deploy to Cloudflare
30 # Production deploys from Diffinery itself, on its own hosted runners (any
31 # Diffinery, so self-hosted copies deploy themselves too). Running this
32 # workflow by hand on GitHub still deploys, as a fallback.
33 if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url != 'https://github.com' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }}
34 runs-on: ubuntu-latest
35 timeout-minutes: 45
36 environment:
37 name: production
38 url: https://${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }}
39 env:
40 DIFFINERY_DOMAIN: ${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }}
41 DIFFINERY_ZONE: ${{ vars.DIFFINERY_ZONE }}
42 MAIL_DOMAIN: ${{ vars.MAIL_DOMAIN }}
43 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
44 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
45 ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }}
46 steps:
47 - uses: actions/checkout@v4
48 with:
49 # The commit CI passed on, not whatever main is by now.
50 ref: ${{ github.event.workflow_run.head_sha || github.sha }}
51 - uses: pnpm/action-setup@v4
52 - uses: actions/setup-node@v4
53 with:
54 node-version: 22
55 cache: pnpm
56 - run: pnpm install --frozen-lockfile
57 - name: Build runner agent and web islands
58 run: pnpm -r --if-present build
59 - name: Runner image (built only when the runner changed)
60 run: scripts/runner-image.sh
61
62 - name: Create or update Cloudflare resources
63 id: bootstrap
64 run: scripts/bootstrap.sh
65
66 - name: Deploy Worker and runner image
67 working-directory: apps/diffinery
68 run: pnpm exec wrangler deploy -c wrangler.deploy.jsonc
69
70 - name: Wait for new containers to get the new image
71 # The Live test follows this workflow, and its checks must run on the new image.
72 # This job runs on an old-image container, so finishing after the rollout's last step
73 # also shows that a deploy doesn't stop a running job.
74 run: scripts/wait-container-rollout.sh
75
76 - name: Worker secrets (generated on the first deploy; TURNSTILE_SECRET when missing)
77 run: scripts/ensure-secrets.sh
78
79 - name: Finish Access setup on first deploy
80 if: steps.bootstrap.outputs.access_team == ''
81 run: |
82 scripts/bootstrap.sh
83 cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none
84
85 - name: Smoke test
86 run: |
87 # A new custom domain can take a few minutes to get its certificate.
88 for i in $(seq 1 30); do
89 code=$(curl -s -o /dev/null -w '%{http_code}' https://$DIFFINERY_DOMAIN/healthz) && [ "$code" = 200 ] && exit 0
90 sleep 10
91 done
92 echo "::error::https://$DIFFINERY_DOMAIN/healthz did not return 200 (last: $code). Check that the $DIFFINERY_DOMAIN DNS record is proxied (orange cloud)."; exit 1
93
94 - name: Check sign-in pages
95 run: |
96 set -u
97 page=$(curl -sS https://$DIFFINERY_DOMAIN/login)
98 grep -q "Sign in with a passkey" <<<"$page" || { echo "::error::/login is not the Diffinery sign-in page"; exit 1; }
99 echo "/login serves the passkey sign-in page"
100 loc=$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' https://$DIFFINERY_DOMAIN/auth/access)
101 echo "/auth/access -> $loc"
102 [[ "$loc" == 302\ https://*.cloudflareaccess.com/* ]] || echo "::warning::/auth/access is not behind Cloudflare Access yet, so email recovery and first admin sign-in will not work"
103 for kind in js css; do
104 asset=$(grep -o "/-/kumo/[a-z0-9]*\.$kind" <<<"$page" | head -1)
105 [ -n "$asset" ] || { echo "::error::/login does not load the Kumo $kind bundle"; exit 1; }
106 code=$(curl -sS -o /dev/null -w '%{http_code}' "https://$DIFFINERY_DOMAIN$asset")
107 [ "$code" = 200 ] || { echo "::error::$asset returned $code"; exit 1; }
108 echo "$asset -> 200"
109 done
110 - name: Check favicon and search metadata
111 run: |
112 set -u
113 for p in /favicon.ico /favicon.svg /favicon-48.png /apple-touch-icon.png /og-image.png /site.webmanifest /robots.txt /sitemap.xml; do
114 code=$(curl -sS -o /dev/null -w '%{http_code} %{content_type}' "https://$DIFFINERY_DOMAIN$p")
115 [[ "$code" == 200\ * ]] || { echo "::error::$p returned $code"; exit 1; }
116 echo "$p -> $code"
117 done
118 home=$(curl -sS https://$DIFFINERY_DOMAIN/)
119 for tag in 'rel="icon" href="/favicon.ico"' 'property="og:image"' 'rel="canonical"' 'application/ld+json'; do
120 grep -qF "$tag" <<<"$home" || { echo "::error::the home page is missing $tag"; exit 1; }
121 done
122 echo "the home page carries the favicon, canonical, Open Graph and structured data tags"
123 bm=$(curl -sS -D - -o /dev/null https://$DIFFINERY_DOMAIN/sitemap.xml | tr -d '\r' | sed -n 's/^x-d1-bookmark: //Ip')
124 [ -n "$bm" ] || { echo "::error::D1 queries don't run in a session (no x-d1-bookmark header)"; exit 1; }
125 echo "D1 sessions are on (bookmark $bm)"