Repository navigation
247 lines (234 loc) · 12.8 KB
/
Copy pathdeploy.yml
File metadata and controls
247 lines (234 loc) · 12.8 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
name: Deploy production
# Deploys a commit whose Validate run on a push to main succeeded, or an explicitly dispatched main commit. Pull request
# runs, other branches and failed or cancelled runs never reach the deploy job.
# workflow_run is used only to observe Validate's verdict for main. No pull request code is
# checked out or executed here; the candidate is a main commit that already passed CI.
# Database credentials exist only inside the GoDaddy runtime, so CI never touches the
# database. A release admitted with an expand-only migration backs the database up to its
# persistent volume and then migrates, once, at startup under the migration lock.
on: # zizmor: ignore[dangerous-triggers]
workflow_run:
workflows: [Validate]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
commit:
description: Full SHA of a main commit whose Validate push run succeeded
required: true
rollback:
description: Allow a commit older than the live release (schema compatibility is still enforced)
type: boolean
default: false
reason:
description: Why this manual deployment is needed
required: true
permissions:
contents: read
# One production release at a time. A newer queued run replaces an older queued one;
# a running deployment (including its migration and cutover) is never cancelled.
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
deploy:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository)
runs-on: ubuntu-24.04
# Worst case: ship <=20, verify <=19 (incl. PREVIEW restart retries), rollback ship+verify <=39 minutes.
timeout-minutes: 80
environment:
name: production
url: ${{ vars.PRODUCTION_URL }}
deployment: false # recorded below against the exact candidate commit instead
permissions:
contents: write # create signed release branches; main is never written by this job
actions: read
deployments: write
env:
APP_ID: ${{ vars.GODADDY_APP_ID }}
PRODUCTION_URL: ${{ vars.PRODUCTION_URL }}
DISCORD_CLIENT_ID: ${{ vars.DISCORD_CLIENT_ID }}
CANDIDATE: ${{ github.event.workflow_run.head_sha || inputs.commit }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GDDY_VERSION: v0.2.29
GDDY_SHA256: d1ec8f525c2f283bc3ee8b8a3604552432f46f7d818ee8a73d73938c7be211a2
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Verify the candidate passed Validate on main
env:
GH_TOKEN: ${{ github.token }}
run: |
[[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::commit must be a full 40-character SHA"; exit 1; }
git merge-base --is-ancestor "$CANDIDATE" origin/main || { echo "::error::$CANDIDATE is not on main"; exit 1; }
passed="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$CANDIDATE&event=push&branch=main&status=success" --jq '.total_count')"
[[ "$passed" -ge 1 ]] || { echo "::error::no successful Validate push run on main for $CANDIDATE"; exit 1; }
echo "Validate passed for $CANDIDATE on main"
- name: Read the live release
id: live
env:
GH_TOKEN: ${{ github.token }}
run: |
health="$(curl -fsS --max-time 20 "$PRODUCTION_URL/api/health" || echo '{}')"
release="$(jq -r '.release // empty' <<<"$health" 2>/dev/null || true)"
storage="$(jq -r '.storage // empty' <<<"$health" 2>/dev/null || true)"
source=health
if [[ -z "$release" ]]; then
# Older releases do not report their commit; fall back to the last recorded success.
source=deployments
for id in $(gh api "repos/$GITHUB_REPOSITORY/deployments?environment=production&per_page=50" --jq '.[].id'); do
if [[ "$(gh api "repos/$GITHUB_REPOSITORY/deployments/$id/statuses?per_page=1" --jq '.[0].state')" == success ]]; then
release="$(gh api "repos/$GITHUB_REPOSITORY/deployments/$id" --jq '.sha')"; break
fi
done
fi
[[ "$release" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::cannot determine the live release"; exit 1; }
git cat-file -e "$release^{commit}" || { echo "::error::live release $release is not in this repository"; exit 1; }
{ echo "release=$release"; echo "storage=$storage"; echo "source=$source"; } | tee -a "$GITHUB_OUTPUT"
- name: Check ordering and schema compatibility
id: guard
env:
LIVE: ${{ steps.live.outputs.release }}
ROLLBACK: ${{ inputs.rollback && '--rollback' || '' }}
run: |
set +e
# shellcheck disable=SC2086 # ROLLBACK is empty or one flag
result="$(node scripts/release-guard.mjs "$LIVE" "$CANDIDATE" $ROLLBACK)"
code=$?
set -e
echo "$result" | jq .
action="$(jq -r .action <<<"$result")"; reason="$(jq -r '.reason // empty' <<<"$result")"
# Validate runs can finish out of order; a newer release already being live is not an error.
if [[ "$GITHUB_EVENT_NAME" == workflow_run && "$reason" == candidate_older_than_live_release ]]; then action=superseded; code=0; fi
migrations="$(jq -r .migrations <<<"$result")"
echo "action=$action" >> "$GITHUB_OUTPUT"
echo "migrations=$migrations" >> "$GITHUB_OUTPUT"
if [[ $code -ne 0 ]]; then echo "::error::release blocked: $reason"; exit 1; fi
# Forward expand-only releases back up and migrate at startup; rollbacks never migrate.
if [[ "$migrations" == expand && "$action" == deploy ]]; then echo "migrate=true" >> "$GITHUB_OUTPUT"; fi
- name: Skip a commit that is already live or superseded
id: noop
if: github.event_name == 'workflow_run' && (steps.guard.outputs.action == 'redeploy' || steps.guard.outputs.action == 'superseded')
env:
ACTION: ${{ steps.guard.outputs.action }}
run: |
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "$CANDIDATE is $ACTION; nothing to deploy." >> "$GITHUB_STEP_SUMMARY"
- name: Record deployment start
id: record
if: steps.noop.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
REASON: ${{ inputs.reason || 'Validate succeeded on main' }}
LIVE: ${{ steps.live.outputs.release }}
ACTION: ${{ steps.guard.outputs.action }}
run: |
id="$(jq -n --arg ref "$CANDIDATE" --arg live "$LIVE" --arg action "$ACTION" --arg reason "$REASON" --arg run "$RUN_URL" \
'{ref: $ref, environment: "production", auto_merge: false, required_contexts: [], production_environment: true,
description: ("TypeCheck " + $action + " from " + $live[0:12]), payload: {live: $live, action: $action, reason: $reason, run: $run}}' \
| gh api "repos/$GITHUB_REPOSITORY/deployments" --input - --jq .id)"
echo "id=$id" >> "$GITHUB_OUTPUT"
gh api "repos/$GITHUB_REPOSITORY/deployments/$id/statuses" -f state=in_progress -f log_url="$RUN_URL" -f environment_url="$PRODUCTION_URL" >/dev/null
- name: Install the GoDaddy CLI (pinned, checksum-verified)
if: steps.noop.outputs.skip != 'true'
run: |
curl -fsSL -o "$RUNNER_TEMP/gddy.tar.gz" "https://github.com/godaddy/cli/releases/download/$GDDY_VERSION/gddy-x86_64-unknown-linux-gnu.tar.gz"
echo "$GDDY_SHA256 $RUNNER_TEMP/gddy.tar.gz" | sha256sum -c -
mkdir -p "$RUNNER_TEMP/gddy" && tar -xzf "$RUNNER_TEMP/gddy.tar.gz" -C "$RUNNER_TEMP/gddy"
sudo install -m 0755 "$(find "$RUNNER_TEMP/gddy" -name gddy -type f | head -1)" /usr/local/bin/gddy
gddy --version
- name: Import the verified release branch and publish
id: ship
if: steps.noop.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
GDDY_PAT: ${{ secrets.GODADDY_PAT }}
MIGRATE: ${{ steps.guard.outputs.migrate == 'true' && 'migrate' || '' }}
# PREVIEW starts the candidate first; with MIGRATE it backs up and applies the migration
# once under the application lock while the previous release keeps serving (expand-only).
run: |
# shellcheck disable=SC2086 # MIGRATE is empty or one word
scripts/godaddy-release.sh ship "$CANDIDATE" "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" $MIGRATE
- name: Verify production
id: verify
if: steps.noop.outputs.skip != 'true'
env:
GDDY_PAT: ${{ secrets.GODADDY_PAT }}
EXPECTED_COMMIT: ${{ env.CANDIDATE }}
EXPECTED_STORAGE: ${{ steps.live.outputs.storage }}
run: scripts/godaddy-release.sh verify
- name: Retrieve the migration or schema-check receipt
id: receipt
if: steps.noop.outputs.skip != 'true'
# Evidence only: readiness does not prove that a backup was made. Missing receipts
# remain explicitly unknown; startup still enforces backup before pending migrations.
continue-on-error: true
env:
GDDY_PAT: ${{ secrets.GODADDY_PAT }}
SINCE: ${{ steps.ship.outputs.started_at }}
run: scripts/godaddy-release.sh receipt "$SINCE"
- name: Collect diagnostics
if: (failure() || cancelled()) && steps.record.outputs.id
env:
GDDY_PAT: ${{ secrets.GODADDY_PAT }}
run: scripts/godaddy-release.sh logs "$RUNNER_TEMP/diagnostics"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: (failure() || cancelled()) && steps.record.outputs.id
with:
name: deployment-diagnostics-${{ github.run_id }}
path: ${{ runner.temp }}/diagnostics
retention-days: 14
- name: Roll back to the previous release
id: rollback
# Safe only because the guard admitted no incompatible schema change; never migrates.
if: (failure() || cancelled()) && steps.ship.outputs.publish_started == 'true' && steps.live.outputs.release != env.CANDIDATE
env:
GH_TOKEN: ${{ github.token }}
GDDY_PAT: ${{ secrets.GODADDY_PAT }}
PREVIOUS: ${{ steps.live.outputs.release }}
PREVIOUS_SOURCE: ${{ steps.live.outputs.source }}
EXPECTED_STORAGE: ${{ steps.live.outputs.storage }}
run: |
scripts/godaddy-release.sh ship "$PREVIOUS" "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
# Releases that predate release reporting are verified by readiness only.
if [[ "$PREVIOUS_SOURCE" == health ]]; then export EXPECTED_COMMIT="$PREVIOUS"; fi
scripts/godaddy-release.sh verify
- name: Record deployment result
if: always() && steps.record.outputs.id
env:
GH_TOKEN: ${{ github.token }}
DEPLOYMENT_ID: ${{ steps.record.outputs.id }}
VERIFY: ${{ steps.verify.outcome }}
ROLLBACK: ${{ steps.rollback.outcome || 'not run' }}
BACKUP: "${{ steps.receipt.outputs.backup || (steps.guard.outputs.migrate == 'true' && 'unknown: migration receipt unavailable' || 'not requested; startup schema receipt unavailable') }}"
GODADDY_DEPLOYMENT: ${{ steps.ship.outputs.godaddy_deployment }}
RELEASE_SOURCE: ${{ steps.ship.outputs.release_source }}
RELEASE_BRANCH: ${{ steps.ship.outputs.release_branch }}
LIVE: ${{ steps.live.outputs.release }}
LIVE_SOURCE: ${{ steps.live.outputs.source }}
ACTION: ${{ steps.guard.outputs.action }}
MIGRATIONS: ${{ steps.guard.outputs.migrations }}
run: |
if [[ "$VERIFY" == success ]]; then state=success; else state=failure; fi
gh api "repos/$GITHUB_REPOSITORY/deployments/$DEPLOYMENT_ID/statuses" -f state="$state" -f log_url="$RUN_URL" -f environment_url="$PRODUCTION_URL" \
-f description="verify=$VERIFY rollback=$ROLLBACK godaddy=$GODADDY_DEPLOYMENT" >/dev/null
{
echo "### Production release"
echo "| | |"; echo "|---|---|"
echo "| Commit | \`$CANDIDATE\` |"
echo "| Source artifact | \`$RELEASE_SOURCE\` (\`$RELEASE_BRANCH\`) |"
echo "| Previous | \`$LIVE\` ($LIVE_SOURCE) |"
echo "| Action | $ACTION, migrations: $MIGRATIONS |"
echo "| Backup | $BACKUP |"
echo "| GoDaddy deployment | $GODADDY_DEPLOYMENT |"
echo "| Result | $state (rollback: $ROLLBACK) |"
echo "| URL | $PRODUCTION_URL |"
} >> "$GITHUB_STEP_SUMMARY"