Repository navigation
122 lines (106 loc) · 5.35 KB
/
Copy pathdeploy.yml
File metadata and controls
122 lines (106 loc) · 5.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
name: deploy
# This is the one workflow in the repository that holds a secret, and
# tests.yml's warning explains why that needs care: pull requests arrive from
# forks. Three things keep this job out of their reach.
#
# 1. It never runs on `pull_request`. `workflow_run` fires only after a run of
# the tests workflow that has already completed on main, and a fork's pull
# request does not produce one of those.
# 2. It does not check the repository out. Nothing from a contributor's branch
# is ever executed on the runner or sent to the server — the job opens an
# SSH connection and nothing else.
# 3. The key it uses cannot open a shell. Its entry in the server's
# authorized_keys pins it to a single forced command, so the most a leaked
# copy could do is deploy main, which is what it is for.
#
# The second secret, the Cloudflare token, can do one thing: purge the cache of
# the tablepro.app zone. It is used on the runner after the deploy and is never
# sent to the server.
#
# See docs/deployment.md for how the key is created and how to revoke it.
on:
workflow_run:
workflows: ["tests"]
types: [completed]
branches: [main]
workflow_dispatch:
permissions:
contents: read
# Two deploys at once would race over the same build directory. Queue them
# instead, and never cancel one that is already running — being interrupted
# between the asset swap and the SSR restart is the one state worth avoiding.
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
# workflow_run fires for failed runs too, so the conclusion has to be
# checked here; there is no `types: [success]` filter to do it declaratively.
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy over SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
set -euo pipefail
for name in DEPLOY_SSH_KEY DEPLOY_KNOWN_HOSTS DEPLOY_HOST DEPLOY_PORT DEPLOY_USER; do
if [ -z "${!name}" ]; then
echo "::error::$name is not set. See docs/deployment.md." >&2
exit 1
fi
done
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
# Pinned rather than discovered with ssh-keyscan: keyscan trusts
# whatever answers, which would accept an impostor on the very first
# connection and defeat the point of checking at all.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
# No command is sent. The server's authorized_keys entry pins this key
# to scripts/deploy.sh, so whatever were written here would be ignored
# — which is the property that makes the key safe to store.
ssh -i ~/.ssh/deploy_key \
-p "$DEPLOY_PORT" \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o ConnectTimeout=20 \
"$DEPLOY_USER@$DEPLOY_HOST"
# Pages are cached at Cloudflare for up to ten minutes (docs/deployment.md,
# "Caching"), so readers see a release once the cache is emptied. This runs
# only after the deploy succeeded, and only here: the token never reaches
# the server. The previous release's hashed assets stay on the server for
# days, so a page cached before the purge still loads.
#
# Without both secrets it says so and passes, so deploys keep working
# before the token exists. `purge_everything` works on every Cloudflare
# plan and needs no list of URLs to keep in step with the site; it also
# empties the cache of any other host in the zone, which only costs those
# hosts a refetch.
- name: Purge Cloudflare's cache
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_CACHE_PURGE_TOKEN: ${{ secrets.CLOUDFLARE_CACHE_PURGE_TOKEN }}
run: |
set -euo pipefail
if [ -z "${CLOUDFLARE_ZONE_ID:-}" ] || [ -z "${CLOUDFLARE_CACHE_PURGE_TOKEN:-}" ]; then
echo "::notice::CLOUDFLARE_ZONE_ID or CLOUDFLARE_CACHE_PURGE_TOKEN is not set, so Cloudflare's cache was not purged. See docs/deployment.md."
exit 0
fi
response="$(curl -sS --max-time 30 --retry 3 --retry-all-errors \
-X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_CACHE_PURGE_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}' || true)"
if ! printf '%s' "$response" | grep -Eq '"success"[[:space:]]*:[[:space:]]*true'; then
echo "::error::Cloudflare did not purge the cache. The release is live at the origin; cached pages catch up within ten minutes, or within the hour Cloudflare may serve them stale. Response: ${response:-none}" >&2
exit 1
fi
echo "Purged Cloudflare's cache for the zone."