Repository navigation
270 lines (250 loc) · 15.2 KB
/
Copy pathdeploy-site-task.yml
File metadata and controls
270 lines (250 loc) · 15.2 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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
name: Deploy site task
# Reusable deploy of a built site to a filesystem on a host the project owns, hosted here once since the environment binding, the deploy-key handling, and the atomic upload-then-flip sequence are identical across every site repo.
# It is a separate dispatch from the release, so a redeploy of an unchanged commit mints no tag.
# That matters because redeploying is routine: a host rebuild, a rollback, or proving a branch on a non-production environment.
# See WORKFLOW.md D4.6 and D5.6.
#
# A required deploy hook, one composite action the caller owns, covers what is genuinely the site's own: assembling the release bundle (its generator, its precompression, whatever it stamps into the bundle), pruning old releases, and verifying the live site against its own URL contract.
# It runs three times, once each for build, prune, and verify, passing mode plus whichever of bundle-path, release-id, and environment that mode needs.
# A hook declares all four inputs in its own action.yml, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset.
# A composite action's own steps are not guaranteed to read the caller's vars context directly, so each invocation also passes the GitHub Environment variables that mode needs (SITE_BASE_URL, DEPLOY_SSH_USER, DEPLOY_SSH_HOST) as plain env vars, the same mechanism the upload and flip steps below use.
# The verify invocation also carries an optional SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN secret pair the same way, for a hook whose live check needs its own token-gated auth.
# It also carries SITE_EXTRA_BASE_URL and an optional SITE_EXTRA_AUTH_TOKEN_ID/SITE_EXTRA_AUTH_TOKEN pair, for a second site the bundle serves on its own hostname behind its own gate.
# The upload and the flip stay here as the one atomic sequence every site repo shares, so a hook cannot fork that guarantee.
#
# The transport's options are pinned rather than left to the runner's OpenSSH defaults, and declared once so the two transfers cannot drift apart.
# StrictHostKeyChecking=yes refuses an unknown or changed host key outright, where the default asks and a non-interactive runner then resolves that ambiguously.
# UserKnownHostsFile names the file the deploy key step writes, so the check reads the pinned value rather than whatever the runner image happens to carry.
# BatchMode=yes makes every prompt an immediate failure, so a credential problem surfaces as a failed step rather than as a job that hangs to its timeout.
# IdentitiesOnly=yes stops the agent offering other keys, so the deploy authenticates as the confined account or not at all.
on:
workflow_call:
inputs:
# Selects the GitHub Environment, and is also the path segment the release lands under on the host.
environment:
description: The GitHub Environment to deploy to.
required: true
type: string
secrets:
# The credentials that cross this reusable workflow's boundary as named secrets.
# The host address, the user, and the known-hosts value are GitHub Environment variables instead, since they are integrity-critical but not confidential.
# The deploy job below reads them directly through its own environment binding.
DEPLOY_SSH_PRIVATE_KEY:
required: true
# Optional token-gated auth pair for the verify hook's own live check, required: false since not every caller needs one.
# Checked as a pair by the assert step below, then forwarded to the hook only on the verify invocation.
SITE_AUTH_TOKEN_ID:
required: false
SITE_AUTH_TOKEN:
required: false
# The second site's own pair, since a gate's token opens only the one resource it was issued for.
# Checked as a pair and against SITE_EXTRA_BASE_URL by the assert step below, then forwarded only on the verify invocation.
SITE_EXTRA_AUTH_TOKEN_ID:
required: false
SITE_EXTRA_AUTH_TOKEN:
required: false
outputs:
# The caller records what shipped, without this a rollback has to read the host to find out.
release-id:
value: ${{ jobs.deploy.outputs.release-id }}
site-url:
value: ${{ jobs.deploy.outputs.site-url }}
env:
SSH_TRANSPORT: >-
ssh -i ~/.ssh/deploy
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile=~/.ssh/known_hosts
-o BatchMode=yes
jobs:
# A job of its own, because the environment binding on the deploy job resolves before any step runs.
# A workflow_call caller is not bound by the dispatch choice list a human sees, so an unknown name would otherwise bind nothing and run anyway.
assert-environment:
name: Assert environment name job
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Assert environment is known step
env:
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -Eeuo pipefail
case "$ENVIRONMENT" in
production | staging) ;;
*)
echo "::error::environment must be production or staging, got '$ENVIRONMENT'."
exit 1
;;
esac
# Every host-specific value comes from the environment, so this file names no host, path, or address.
# The caller still maps DEPLOY_SSH_PRIVATE_KEY via workflow_call.secrets (required below), but this job's own binding is what determines the real value: cross-repository it resolves from the caller's environments, per docs/reusable-workflows.md "Deploy-site." (a caller job with uses: cannot carry environment: itself).
deploy:
name: Deploy site job
runs-on: ubuntu-latest
needs: [assert-environment]
environment: ${{ inputs.environment }}
permissions:
contents: read
outputs:
release-id: ${{ steps.release.outputs.id }}
site-url: ${{ vars.SITE_BASE_URL }}
steps:
# Full history, because a shallow clone silently changes page metadata where the generator reads git info.
- name: Checkout code step
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# Fail fast on a misconfigured environment, before any build, upload, or flip work runs.
# A silently empty variable or secret would otherwise let the deploy proceed and only surface as a broken site-url output or a late SSH/rsync failure.
# GitHub enforces only that the caller maps a required secret, never that the mapped value is non-empty, so an unset environment secret reaches here as an empty string.
- name: Assert environment variables and secrets are set step
env:
SITE_BASE_URL: ${{ vars.SITE_BASE_URL }}
DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }}
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
DEPLOY_SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_SSH_KNOWN_HOSTS }}
DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
SITE_AUTH_TOKEN_ID: ${{ secrets.SITE_AUTH_TOKEN_ID }}
SITE_AUTH_TOKEN: ${{ secrets.SITE_AUTH_TOKEN }}
SITE_EXTRA_BASE_URL: ${{ vars.SITE_EXTRA_BASE_URL }}
SITE_EXTRA_AUTH_TOKEN_ID: ${{ secrets.SITE_EXTRA_AUTH_TOKEN_ID }}
SITE_EXTRA_AUTH_TOKEN: ${{ secrets.SITE_EXTRA_AUTH_TOKEN }}
run: |
set -Eeuo pipefail
missing=()
[ -n "$SITE_BASE_URL" ] || missing+=(SITE_BASE_URL)
[ -n "$DEPLOY_SSH_USER" ] || missing+=(DEPLOY_SSH_USER)
[ -n "$DEPLOY_SSH_HOST" ] || missing+=(DEPLOY_SSH_HOST)
[ -n "$DEPLOY_SSH_KNOWN_HOSTS" ] || missing+=(DEPLOY_SSH_KNOWN_HOSTS)
[ -n "$DEPLOY_SSH_PRIVATE_KEY" ] || missing+=(DEPLOY_SSH_PRIVATE_KEY)
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::Missing or empty GitHub Environment value(s) on '${{ inputs.environment }}': ${missing[*]}"
exit 1
fi
# SITE_AUTH_TOKEN_ID and SITE_AUTH_TOKEN are each optional, but only together.
# A caller that maps one without the other fails here instead of reaching the verify hook with a partial credential.
if { [ -n "$SITE_AUTH_TOKEN_ID" ] && [ -z "$SITE_AUTH_TOKEN" ]; } || { [ -z "$SITE_AUTH_TOKEN_ID" ] && [ -n "$SITE_AUTH_TOKEN" ]; }; then
echo "::error::SITE_AUTH_TOKEN_ID and SITE_AUTH_TOKEN must both be mapped or neither; got only one."
exit 1
fi
# The second site's pair follows the same rule, and a pair with no SITE_EXTRA_BASE_URL names no site to check.
if { [ -n "$SITE_EXTRA_AUTH_TOKEN_ID" ] && [ -z "$SITE_EXTRA_AUTH_TOKEN" ]; } || { [ -z "$SITE_EXTRA_AUTH_TOKEN_ID" ] && [ -n "$SITE_EXTRA_AUTH_TOKEN" ]; }; then
echo "::error::SITE_EXTRA_AUTH_TOKEN_ID and SITE_EXTRA_AUTH_TOKEN must both be mapped or neither; got only one."
exit 1
fi
if [ -n "$SITE_EXTRA_AUTH_TOKEN_ID" ] && [ -z "$SITE_EXTRA_BASE_URL" ]; then
echo "::error::SITE_EXTRA_AUTH_TOKEN_ID and SITE_EXTRA_AUTH_TOKEN are mapped, but SITE_EXTRA_BASE_URL is missing or empty on '${{ inputs.environment }}'."
exit 1
fi
# Derived once and used three times: the directory name, the stamp the bundle carries, and the value the live check expects.
# Deriving it twice yields ids seconds apart, and the check then asserts a version nothing installed.
- name: Resolve release id step
id: release
run: |
set -Eeuo pipefail
echo "id=$(date -u +%Y%m%d-%H%M%S)-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
# Assembled to a scratch path, since the environment's deploy root is on the far host.
# The hook owns the generator, any precompression, and stamping the release id into whatever the bundle serves.
- name: Run deploy hook build step
if: ${{ hashFiles('.github/actions/deploy/action.yml') != '' }}
uses: ./.github/actions/deploy
env:
SITE_BASE_URL: ${{ vars.SITE_BASE_URL }}
with:
mode: build
bundle-path: ${{ runner.temp }}/bundle
release-id: ${{ steps.release.outputs.id }}
- name: Missing deploy hook step
if: ${{ hashFiles('.github/actions/deploy/action.yml') == '' }}
run: |
set -Eeuo pipefail
echo "::error::Required hook missing: .github/actions/deploy/action.yml"
exit 1
# Asserted here rather than left to the upload's rsync error, so a hook that misses the contract fails naming the hook rather than a path that looks like a transport problem.
- name: Assert build hook produced the bundle contract step
env:
RELEASE_ID: ${{ steps.release.outputs.id }}
run: |
set -Eeuo pipefail
if [ ! -d "${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}" ]; then
echo "::error::deploy hook (mode: build) did not create bundle/releases/${RELEASE_ID}/"
exit 1
fi
if [ ! -e "${RUNNER_TEMP}/bundle/current" ]; then
echo "::error::deploy hook (mode: build) did not create bundle/current"
exit 1
fi
# The known-hosts value is a variable rather than a secret, it is integrity-critical but not confidential, and a variable keeps it diff-visible.
# StrictHostKeyChecking is never disabled.
- name: Install deploy key step
env:
DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
DEPLOY_SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_SSH_KNOWN_HOSTS }}
run: |
set -Eeuo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/deploy
chmod 600 ~/.ssh/deploy
printf '%s\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
# The destination is anchored at the key's confinement root, so no host path appears here.
# The hard-link source points at current, which still resolves to the previous release until the flip, so CI carries no state.
# A missing target is a warning rather than an error, so a first deploy into a fresh environment needs no special case.
# The mkpath flag creates the releases parent, which the transport will not create on its own.
# No delete flag: at an environment root it silently removes the releases a rollback needs.
- name: Upload release step
env:
DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }}
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
RELEASE_ID: ${{ steps.release.outputs.id }}
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -Eeuo pipefail
rsync -az --mkpath --no-g --chmod=D2755,F644 \
--link-dest="/${ENVIRONMENT}/current/" \
-e "$SSH_TRANSPORT" \
"${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}/" \
"${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/releases/${RELEASE_ID}/"
# A separate step from the upload, so a failed transfer cannot half-publish a site.
# The pointer is relative, so one bundle works at any remote root.
# The transport replaces a symlink through a temporary and a rename, so it is never absent to a request in flight.
- name: Flip current step
env:
DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }}
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -Eeuo pipefail
rsync -a --no-recursive \
-e "$SSH_TRANSPORT" \
"${RUNNER_TEMP}/bundle/current" \
"${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/"
# Retention is bounded and owned per WORKFLOW.md D5.6, and pruning is the hook's own concern since only a repo whose credential can observe the destination runs this mode at all.
# A credential confined write-only skips this by carrying a hook whose prune mode is a no-op, and the repo's runbook records the host-side timer that owns it instead.
- name: Run deploy hook prune step
if: ${{ hashFiles('.github/actions/deploy/action.yml') != '' }}
uses: ./.github/actions/deploy
env:
DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }}
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
with:
mode: prune
environment: ${{ inputs.environment }}
# The only step that observes the running site (WORKFLOW.md D4.6).
# An upload succeeds against a container serving nothing, and a flip succeeds against a server that never reloads its rules.
# The hook asserts its own golden-list floors, the environment, the release id, and its URL contract, however it chooses to observe them.
- name: Run deploy hook verify step
if: ${{ hashFiles('.github/actions/deploy/action.yml') != '' }}
uses: ./.github/actions/deploy
env:
SITE_BASE_URL: ${{ vars.SITE_BASE_URL }}
SITE_AUTH_TOKEN_ID: ${{ secrets.SITE_AUTH_TOKEN_ID }}
SITE_AUTH_TOKEN: ${{ secrets.SITE_AUTH_TOKEN }}
SITE_EXTRA_BASE_URL: ${{ vars.SITE_EXTRA_BASE_URL }}
SITE_EXTRA_AUTH_TOKEN_ID: ${{ secrets.SITE_EXTRA_AUTH_TOKEN_ID }}
SITE_EXTRA_AUTH_TOKEN: ${{ secrets.SITE_EXTRA_AUTH_TOKEN }}
with:
mode: verify
environment: ${{ inputs.environment }}
release-id: ${{ steps.release.outputs.id }}