forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
227 lines (209 loc) · 10 KB
/
Copy pathcheck-deployment.yml
File metadata and controls
227 lines (209 loc) · 10 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
# Asks daily, as a browser would, whether a hiker can still download the map.
#
# features/../pipeline/DATA_RELEASES.md §3a is the design; this is tier 1 of
# #431. The failure it exists for is #427: the R2 bucket's CORS allow-list lost
# `https://ourhike.github.io`, so the deployed app drew a topo sheet with no
# Appalachian Trail on it for eight days, while the published data was correct
# the whole time and every check in this repository stayed green.
#
# THE ONE HEADER THAT MAKES THIS DIFFERENT. Every other check sends no
# `Origin`, and the bucket answered all of them perfectly throughout the
# outage - a ranged GET returned 206 with `Content-Range`, `ETag` and
# `Accept-Ranges` intact. `Origin` is the single header that decides whether a
# browser may read the response, so this job sends one for every declared
# origin. That alone would have caught #427 on the first run after the policy
# changed.
#
# THE SEPARATION IS THE SAME AS check-upstream-freshness.yml's. This job holds
# no credentials at all - not "chooses not to write", cannot. Its whole input
# is public HTTPS requests to the same bucket a hiker's phone reads, so it is
# structurally incapable of changing anyone's map even if something went badly
# wrong inside it.
#
# IT DOES NOT FAIL THE RUN, and that is deliberate rather than lax. GitHub
# emails on a scheduled workflow's failure, every run, so a genuine outage
# lasting a week would send seven identical emails and the eighth would be
# filtered. #431 is explicit: alert on transitions, not on runs. The tracking
# issue below is the signal - opening it notifies, updating its body does not,
# and the all-clear comment notifies once. The run itself fails only if the
# check crashed.
name: Check deployment
on:
schedule:
# 09:15 UTC daily. Off the hour because GitHub queues everything submitted
# at :00, and clear of the other scheduled jobs - freshness at 07:20,
# settings at 07:35, schema-drift at 08:10, publish-conditions at 08:40.
# Deliberately AFTER publish-conditions: if a publish breaks something,
# this is the job that should notice, and noticing on the same day is the
# whole point.
- cron: "15 9 * * *"
workflow_dispatch:
inputs:
base:
description: "Bucket base to check. Defaults to the DATA_BASE_URL variable."
type: string
required: false
# Read the repo, write the one tracking issue. Nothing that could reach R2.
permissions:
contents: read
issues: write
# One at a time, and never cancel a run in flight: a half-finished check that
# got cancelled would leave the tracking issue describing a comparison that
# never completed.
concurrency:
group: check-deployment
cancel-in-progress: false
env:
ISSUE_TITLE: "Deployed app reachability"
ISSUE_LABEL: deployment-health
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: pipeline
steps:
- name: Is there a bucket to check
id: can
# Before the checkout, so not in pipeline/ - publish-conditions.yml's
# gate has the reasoning for testing the setting this way rather than
# letting the check fail with something less legible.
working-directory: .
env:
OVERRIDE: ${{ inputs.base }}
CONFIGURED: ${{ vars.DATA_BASE_URL }}
run: |
BASE="${OVERRIDE:-$CONFIGURED}"
if [ -z "$BASE" ]; then
echo "::warning::No DATA_BASE_URL is set, so there is no deployment to check. See LAUNCH_CHECKLIST.md step 2."
echo "go=false" >> "$GITHUB_OUTPUT"
else
echo "go=true" >> "$GITHUB_OUTPUT"
echo "base=$BASE" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
if: steps.can.outputs.go == 'true'
- uses: actions/setup-python@v5
if: steps.can.outputs.go == 'true'
with:
python-version: "3.14"
cache: pip
cache-dependency-path: pipeline/requirements.txt
- name: Install dependencies
if: steps.can.outputs.go == 'true'
run: pip install -r requirements.txt
# BASE arrives via env rather than ${{ }} inside the script: the value
# can come verbatim from the workflow_dispatch input, and an expression
# expansion is pasted into the shell as literal text before bash parses
# it. pages.yml documents the same rule.
#
# --exit-zero is what keeps a real outage from emailing daily; see the
# header. Non-zero is therefore reserved for the check itself crashing -
# and seeing that takes machinery, because a pipeline's exit status is
# tee's under the default shell (#514's mechanism). This comment used to
# promise the step "still fails on a non-zero exit" while the pipe
# swallowed every one (#655): a crash after deployment.json was written
# would have handed the tracking-issue step a half-written verdict as
# the day's truth. The status is read back out of PIPESTATUS, the same
# pattern check-upstream-freshness.yml has carried all along.
- name: Ask the bucket as a browser would
id: check
if: steps.can.outputs.go == 'true'
env:
BASE: ${{ steps.can.outputs.base }}
run: |
set +e
python check_deployment.py \
--base "$BASE" \
--json deployment.json \
--exit-zero | tee deployment.txt
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "::error::check_deployment.py exited $status, which --exit-zero reserves for a crash."
exit "$status"
fi
- name: Write the job summary
if: always() && steps.can.outputs.go == 'true'
run: |
{
echo "## Deployed app reachability"
echo
if [ -f deployment.txt ]; then
echo '```'
cat deployment.txt
echo '```'
else
echo "The check did not produce a report. See the step log above."
fi
} >> "$GITHUB_STEP_SUMMARY"
# ONE issue, updated in place - never a second issue, never a comment per
# run. Opening notifies the codeowners; updating a body does not, which is
# what makes a week-long outage cost one email rather than seven; the
# all-clear comment notifies once and closes.
#
# Only REFUSALS open it. A request that never completed says nothing about
# the CORS policy, and #431 is explicit that a flaky third party must not
# be able to declare an outage.
- name: Open, update, or close the tracking issue
if: steps.can.outputs.go == 'true'
uses: actions/github-script@v7
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const fs = require('fs')
const trackingIssue = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/tracking-issue.js`)
const verdict = JSON.parse(fs.readFileSync('pipeline/deployment.json', 'utf8'))
const failed = verdict.failed ?? []
const blocking = verdict.hiker_facing_failures ?? []
const unreachable = verdict.unreachable ?? []
const keyOf = report => `${report.check}:${report.origin ?? report.key ?? ''}`
await trackingIssue({github, context, core}, {
label: process.env.ISSUE_LABEL,
title: process.env.ISSUE_TITLE,
checkedAt: verdict.checked_at,
keys: failed.map(keyOf),
// The all-clear also has to have actually LOOKED (#651): a run
// where the artifact checks never ran - manifest and release
// index both unreadable, say - proves recovery of nothing, and
// must not close an outage issue on the strength of CORS answers
// a 404 carries too. Older verdicts lack the field; undefined
// does not block, so only a run that says "false" holds back.
//
// This stays here rather than moving into the shared module
// precisely because smoke-published.yml's version of the same
// sentence is a different sentence.
healthy: failed.length === 0 && verdict.checked_artifacts !== false,
allClear: `A browser can download the map from every declared origin again, as of ${verdict.checked_at}. Closing.`,
render: (firstSeen, cell) => {
const row = report => {
const key = keyOf(report)
const stops = blocking.some(b => keyOf(b) === key) ? '**yes**' : 'no'
return `| \`${key}\` | ${stops} | ${firstSeen[key]} | ${cell(report.detail)} |`
}
return [
blocking.length
? `**A hiker cannot download the map.** ${blocking.length} of ${failed.length} failing check(s) block the deployed app.`
: `${failed.length} check(s) failed, none of which stop a hiker - previews or local development only.`,
'',
`Checked \`${verdict.base}\`.`,
'',
'| check | stops a hiker | first seen | detail |',
'|---|---|---|---|',
...failed.map(row),
'',
unreachable.length
? `${unreachable.length} check(s) could not be made at all and are not counted as refusals - a flaky third party must not declare an outage.`
: '',
'',
'The CORS policy to paste into Cloudflare is generated from `.github/expected-origins.yml` — run `python pipeline/check_deployment.py --print-cors-policy`. Do not hand-edit a copy.',
'',
`Last checked ${verdict.checked_at} · [run](${process.env.RUN_URL})`,
'',
'---',
'_Generated by [Claude Code](https://claude.ai/code)_',
].join('\n')
},
})