| 1 | name: D1 read replication |
| 2 | |
| 3 | # Checks the D1 Sessions API with read replication on a throwaway database, then |
| 4 | # (when asked) turns read replication on for the production database. |
| 5 | # Uses the repository secrets CLOUDFLARE_API_TOKEN (D1 and Workers Scripts: Edit) |
| 6 | # and CLOUDFLARE_ACCOUNT_ID. |
| 7 | on: |
| 8 | workflow_dispatch: |
| 9 | inputs: |
| 10 | enable_production: |
| 11 | description: Turn on read replication for the production database after the check passes |
| 12 | type: boolean |
| 13 | default: false |
| 14 | |
| 15 | permissions: |
| 16 | contents: read |
| 17 | |
| 18 | jobs: |
| 19 | check: |
| 20 | name: Check sessions on a test database |
| 21 | runs-on: ubuntu-latest |
| 22 | # The Cloudflare secrets are the production environment's. |
| 23 | environment: production |
| 24 | timeout-minutes: 20 |
| 25 | env: |
| 26 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 27 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 28 | NAME: diffinery-d1-probe-${{ github.run_id }} |
| 29 | steps: |
| 30 | - uses: actions/checkout@v4 |
| 31 | - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 |
| 32 | - uses: actions/setup-node@v4 |
| 33 | with: |
| 34 | node-version: 22 |
| 35 | cache: pnpm |
| 36 | - run: pnpm install --frozen-lockfile |
| 37 | - name: Create a test database with read replication |
| 38 | run: | |
| 39 | set -euo pipefail |
| 40 | API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID" |
| 41 | auth=(-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json") |
| 42 | id=$(curl -sS -X POST "$API/d1/database" "${auth[@]}" --data "{\"name\":\"$NAME\"}" | jq -r '.result.uuid // empty') |
| 43 | [ -n "$id" ] || { echo "::error::could not create the test database"; exit 1; } |
| 44 | echo "D1_ID=$id" >> "$GITHUB_ENV" |
| 45 | curl -sS -X PUT "$API/d1/database/$id" "${auth[@]}" --data '{"read_replication":{"mode":"auto"}}' | jq -e '.success' >/dev/null |
| 46 | mode=$(curl -sS "$API/d1/database/$id" "${auth[@]}" | jq -r '.result.read_replication.mode') |
| 47 | echo "read replication on the test database: $mode" |
| 48 | [ "$mode" = auto ] || { echo "::error::read replication did not turn on"; exit 1; } |
| 49 | - name: Deploy the probe Worker |
| 50 | working-directory: scripts/d1-probe |
| 51 | run: | |
| 52 | set -euo pipefail |
| 53 | cat > wrangler.json <<JSON |
| 54 | {"name":"$NAME","main":"worker.js","compatibility_date":"2026-10-01","workers_dev":true, |
| 55 | "d1_databases":[{"binding":"DB","database_name":"$NAME","database_id":"$D1_ID"}]} |
| 56 | JSON |
| 57 | W=(pnpm --dir "$GITHUB_WORKSPACE/apps/diffinery" exec wrangler) |
| 58 | "${W[@]}" d1 execute "$NAME" --remote -c "$PWD/wrangler.json" --command "CREATE TABLE probe (k TEXT PRIMARY KEY, v TEXT NOT NULL)" |
| 59 | "${W[@]}" deploy -c "$PWD/wrangler.json" |
| 60 | sub=$(curl -sS "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/subdomain" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result.subdomain // empty') |
| 61 | [ -n "$sub" ] || { echo "::error::the account has no workers.dev subdomain"; exit 1; } |
| 62 | echo "PROBE_URL=https://$NAME.$sub.workers.dev" >> "$GITHUB_ENV" |
| 63 | - name: Read your own writes through bookmarks |
| 64 | run: | |
| 65 | set -uo pipefail |
| 66 | for i in $(seq 1 30); do |
| 67 | body=$(curl -sS "$PROBE_URL/check?rounds=25") && jq -e . >/dev/null <<<"$body" && break |
| 68 | sleep 5 |
| 69 | done |
| 70 | echo "$body" | jq . |
| 71 | jq -e '.ok' >/dev/null <<<"$body" || { echo "::error::a read after a bookmark missed the write, or a query failed"; exit 1; } |
| 72 | echo "every read from a bookmark saw its write ($(jq -r '.replicaReads' <<<"$body") served by replicas)" |
| 73 | - name: Remove the probe and the test database |
| 74 | if: always() |
| 75 | run: | |
| 76 | cd scripts/d1-probe |
| 77 | [ -f wrangler.json ] && pnpm --dir "$GITHUB_WORKSPACE/apps/diffinery" exec wrangler delete -c "$PWD/wrangler.json" --force || true |
| 78 | [ -n "${D1_ID:-}" ] && curl -sS -X DELETE "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/d1/database/$D1_ID" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -c '{success}' || true |
| 79 | |
| 80 | enable: |
| 81 | name: Turn on read replication in production |
| 82 | needs: check |
| 83 | if: ${{ inputs.enable_production }} |
| 84 | runs-on: ubuntu-latest |
| 85 | environment: |
| 86 | name: production |
| 87 | url: https://diffinery.com |
| 88 | env: |
| 89 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 90 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 91 | steps: |
| 92 | - name: Enable and verify |
| 93 | run: | |
| 94 | set -euo pipefail |
| 95 | API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID" |
| 96 | auth=(-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json") |
| 97 | id=$(curl -sS "$API/d1/database?name=diffinery&per_page=100" "${auth[@]}" | jq -r '.result[]? | select(.name=="diffinery") | .uuid' | head -n1) |
| 98 | [ -n "$id" ] || { echo "::error::production database not found"; exit 1; } |
| 99 | curl -sS -X PUT "$API/d1/database/$id" "${auth[@]}" --data '{"read_replication":{"mode":"auto"}}' | jq -e '.success' >/dev/null |
| 100 | mode=$(curl -sS "$API/d1/database/$id" "${auth[@]}" | jq -r '.result.read_replication.mode') |
| 101 | echo "read replication on diffinery: $mode" |
| 102 | [ "$mode" = auto ] || { echo "::error::read replication did not turn on"; exit 1; } |
| 103 | - name: Check the site still reads and writes |
| 104 | run: | |
| 105 | set -euo pipefail |
| 106 | for p in /healthz /login /sitemap.xml; do |
| 107 | code=$(curl -sS -o /dev/null -w '%{http_code}' "https://diffinery.com$p"); echo "$p -> $code"; [ "$code" = 200 ] |
| 108 | done |
| 109 | bm=$(curl -sS -D - -o /dev/null https://diffinery.com/sitemap.xml | tr -d '\r' | sed -n 's/^x-d1-bookmark: //Ip') |
| 110 | echo "x-d1-bookmark: $bm"; [ -n "$bm" ] |