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.

185 lines · 9.2 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.
13# Only main's current tip deploys: re-running an old CI run (which anyone with write access
14# can do) doesn't bring back a version from before a fix.
15#
16# The Cloudflare secrets are the production environment's, and only the steps that talk to
17# Cloudflare get them: installs, builds and third-party actions run without them.
18on:
19 workflow_run:
20 workflows: [CI]
21 types: [completed]
22 branches: [main]
23 workflow_dispatch:
24
25permissions:
26 contents: read
27
28concurrency:
29 group: deploy-production
30 cancel-in-progress: false
31
32jobs:
33 gate:
34 name: Main's current tip
35 # Production deploys from Diffinery itself, on its own hosted runners (any
36 # Diffinery, so self-hosted copies deploy themselves too). Running this
37 # workflow by hand on GitHub still deploys, as a fallback.
38 if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url != 'https://github.com' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }}
39 runs-on: ubuntu-latest
40 timeout-minutes: 5
41 outputs:
42 deploy: ${{ steps.tip.outputs.deploy }}
43 steps:
44 - name: Deploy only main's current tip
45 id: tip
46 env:
47 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48 WANT: ${{ github.event.workflow_run.head_sha || github.sha }}
49 REF: ${{ github.ref }}
50 run: |
51 set -euo pipefail
52 if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$REF" != refs/heads/main ]; then
53 echo "::error::Deploy runs from main only (this run is on $REF)"; exit 1
54 fi
55 tip=$(curl -fsS "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/branches/main" -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" | jq -r .commit.sha)
56 if [ "$tip" = "$WANT" ]; then
57 echo "deploy=true" >> "$GITHUB_OUTPUT"
58 echo "deploying $WANT, main's tip"
59 else
60 # A later commit's CI run deploys it: a re-run of an old CI run deploys nothing.
61 echo "deploy=false" >> "$GITHUB_OUTPUT"
62 echo "::notice::not deploying $WANT: main is at $tip now, and that commit's own run deploys it"
63 fi
64
65 deploy:
66 name: Deploy to Cloudflare
67 needs: gate
68 if: ${{ needs.gate.outputs.deploy == 'true' }}
69 runs-on: ubuntu-latest
70 timeout-minutes: 45
71 environment:
72 name: production
73 url: https://${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }}
74 env:
75 DIFFINERY_DOMAIN: ${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }}
76 DIFFINERY_ZONE: ${{ vars.DIFFINERY_ZONE }}
77 MAIL_DOMAIN: ${{ vars.MAIL_DOMAIN }}
78 ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }}
79 steps:
80 - uses: actions/checkout@v4
81 with:
82 # The commit CI passed on, not whatever main is by now.
83 ref: ${{ github.event.workflow_run.head_sha || github.sha }}
84 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
85 - uses: actions/setup-node@v4
86 with:
87 node-version: 22
88 cache: pnpm
89 - run: pnpm install --frozen-lockfile
90 - name: Build runner agent and web islands
91 run: pnpm -r --if-present build
92 - name: Runner image (built only when the runner changed)
93 env:
94 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
95 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
96 run: scripts/runner-image.sh
97
98 - name: Create or update Cloudflare resources
99 id: bootstrap
100 env:
101 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
102 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
103 run: scripts/bootstrap.sh
104
105 - name: Deploy Worker and runner image
106 working-directory: apps/diffinery
107 env:
108 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
109 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
110 run: pnpm exec wrangler deploy -c wrangler.deploy.jsonc
111
112 - name: Wait for new containers to get the new image
113 env:
114 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
115 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
116 # The Live test follows this workflow, and its checks must run on the new image.
117 # This job runs on an old-image container, so finishing after the rollout's last step
118 # also shows that a deploy doesn't stop a running job.
119 run: scripts/wait-container-rollout.sh
120
121 - name: Worker secrets (generated on the first deploy; TURNSTILE_SECRET when missing)
122 env:
123 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
124 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
125 run: scripts/ensure-secrets.sh
126
127 - name: Finish Access setup on first deploy
128 if: steps.bootstrap.outputs.access_team == ''
129 env:
130 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
131 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
132 run: |
133 scripts/bootstrap.sh
134 cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none
135
136 - name: Smoke test
137 run: |
138 # A new custom domain can take a few minutes to get its certificate.
139 for i in $(seq 1 30); do
140 code=$(curl -s -o /dev/null -w '%{http_code}' https://$DIFFINERY_DOMAIN/healthz) && [ "$code" = 200 ] && exit 0
141 sleep 10
142 done
143 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
144
145 - name: Check sign-in pages
146 run: |
147 set -u
148 page=$(curl -sS https://$DIFFINERY_DOMAIN/login)
149 grep -q "Sign in with a passkey" <<<"$page" || { echo "::error::/login is not the Diffinery sign-in page"; exit 1; }
150 echo "/login serves the passkey sign-in page"
151 loc=$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' https://$DIFFINERY_DOMAIN/auth/access)
152 echo "/auth/access -> $loc"
153 [[ "$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"
154 report=$(curl -sS -w '\n%{http_code}' https://$DIFFINERY_DOMAIN/security/report)
155 code=${report##*$'\n'}
156 if grep -q 'class="cf-turnstile"' <<<"$report"; then
157 echo "/security/report shows Turnstile to people who aren't signed in"
158 elif grep -q 'has no Turnstile configured' <<<"$report"; then
159 echo "::warning::/security/report has no Turnstile, so only people who are signed in can send security reports: the Worker lacks TURNSTILE_SITE_KEY or TURNSTILE_SECRET (see the bootstrap and Worker secrets steps)"
160 else
161 echo "::warning::/security/report has no Turnstile, so only people who are signed in can send security reports: it answered $code without the form"
162 fi
163 for kind in js css; do
164 asset=$(grep -o "/-/kumo/[a-z0-9]*\.$kind" <<<"$page" | head -1)
165 [ -n "$asset" ] || { echo "::error::/login does not load the Kumo $kind bundle"; exit 1; }
166 code=$(curl -sS -o /dev/null -w '%{http_code}' "https://$DIFFINERY_DOMAIN$asset")
167 [ "$code" = 200 ] || { echo "::error::$asset returned $code"; exit 1; }
168 echo "$asset -> 200"
169 done
170 - name: Check favicon and search metadata
171 run: |
172 set -u
173 for p in /favicon.ico /favicon.svg /favicon-48.png /apple-touch-icon.png /og-image.png /site.webmanifest /robots.txt /sitemap.xml; do
174 code=$(curl -sS -o /dev/null -w '%{http_code} %{content_type}' "https://$DIFFINERY_DOMAIN$p")
175 [[ "$code" == 200\ * ]] || { echo "::error::$p returned $code"; exit 1; }
176 echo "$p -> $code"
177 done
178 home=$(curl -sS https://$DIFFINERY_DOMAIN/)
179 for tag in 'rel="icon" href="/favicon.ico"' 'property="og:image"' 'rel="canonical"' 'application/ld+json'; do
180 grep -qF "$tag" <<<"$home" || { echo "::error::the home page is missing $tag"; exit 1; }
181 done
182 echo "the home page carries the favicon, canonical, Open Graph and structured data tags"
183 bm=$(curl -sS -D - -o /dev/null https://$DIFFINERY_DOMAIN/sitemap.xml | tr -d '\r' | sed -n 's/^x-d1-bookmark: //Ip')
184 [ -n "$bm" ] || { echo "::error::D1 queries don't run in a session (no x-d1-bookmark header)"; exit 1; }
185 echo "D1 sessions are on (bookmark $bm)"