-
Notifications
You must be signed in to change notification settings - Fork 1
181 lines (159 loc) · 8.02 KB
/
Copy pathdeploy-watch.yml
File metadata and controls
181 lines (159 loc) · 8.02 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
# Deploy watchdog — the half of the twelve-day gap that CI structurally cannot see.
#
# ci.yml catches a broken build BEFORE merge. Nothing catches a deploy that
# fails AFTER it, because a failed Railway deploy is invisible from outside:
# the previously-built container keeps serving, the health check keeps passing,
# and the domain keeps answering 200. A broken deploy and a healthy one are
# externally identical. The only record is a red row in a dashboard, and
# dashboards are pull-based — somebody has to decide to look. Nobody did, for
# twelve days.
#
# So this polls and then SHOUTS: a failed run, plus a GitHub issue that opens on
# failure and closes on recovery.
#
# See context-v/issues/A-Failed-Deploy-Is-Silent-Nothing-Watches-Production-After-Merge.md
name: Deploy watch
on:
schedule:
# Every 15 minutes. Two API calls per run = 192 requests/day, comfortable
# against Railway's 1000/hour Hobby limit. Do not tighten below ~10 min
# without checking the plan's rate limit.
- cron: "*/15 * * * *"
workflow_dispatch:
# Never let two watchdog runs race on the same issue.
concurrency:
group: deploy-watch
cancel-in-progress: false
permissions:
contents: read
issues: write
jobs:
check:
name: Railway deploy status
runs-on: ubuntu-latest
steps:
- name: Query Railway for every service's latest deployment
id: check
env:
# A PROJECT token — scoped to one environment of one project, and the
# least-privilege choice: an account token would hand this workflow
# every project in the workspace. Project tokens authenticate with the
# `Project-Access-Token` header, NOT `Authorization: Bearer`.
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: |
set -euo pipefail
if [ -z "${RAILWAY_TOKEN:-}" ]; then
echo "::error::RAILWAY_TOKEN secret is not set. See the context-v issue for how to mint a project token."
exit 1
fi
# Trim whitespace. A secret set from a clipboard or a file almost
# always carries a trailing newline, and Railway reports a token with
# one stray byte as flatly "not found" — indistinguishable from the
# wrong KIND of token, which is what sent the first diagnosis astray.
TOKEN=$(printf '%s' "${RAILWAY_TOKEN}" | tr -d '[:space:]')
echo "Token shape: raw=${#RAILWAY_TOKEN} trimmed=${#TOKEN} chars"
if printf '%s' "$TOKEN" | grep -qiE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then
echo "Token shape: matches the UUID form Railway project tokens take."
else
echo "Token shape: NOT a bare UUID — check for a copied prefix or a truncated paste."
fi
api() {
curl -sS -X POST https://backboard.railway.com/graphql/v2 \
-H "Project-Access-Token: ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$1"
}
# The token knows which environment it belongs to, so no Railway IDs
# are committed here. Swapping the secret repoints the whole workflow.
PROBE=$(api '{"query":"query { projectToken { environmentId } }"}' || true)
ENV_ID=$(printf '%s' "$PROBE" | jq -r '.data.projectToken.environmentId // empty')
if [ -z "$ENV_ID" ]; then
echo "::error::Could not resolve environmentId from the token."
echo "Project-Access-Token response:"
printf '%s\n' "$PROBE" | jq . 2>/dev/null || printf '%s\n' "$PROBE"
# Identify what the credential actually IS, so the next step is a
# fact rather than a guess. An account/team token answers `me`.
echo "Retrying the same token as an ACCOUNT token (Authorization: Bearer):"
curl -sS -X POST https://backboard.railway.com/graphql/v2 \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"query":"query { me { email } }"}' | jq . 2>/dev/null || true
exit 1
fi
api "$(jq -nc --arg id "$ENV_ID" '{
query: "query($id: String!) { environment(id: $id) { serviceInstances { edges { node { serviceName latestDeployment { status createdAt } } } } } }",
variables: {id: $id}
}')" > status.json
# An empty service list must NEVER read as healthy. Without this, any
# error on the query above yields zero rows, zero FAILED matches, and
# a green run — a watchdog reporting all-clear precisely because it
# could not see, which is the failure it was built to end.
COUNT=$(jq -r '.data.environment.serviceInstances.edges | length // 0' status.json 2>/dev/null || echo 0)
if [ "${COUNT:-0}" -eq 0 ]; then
echo "::error::Railway returned no services. Refusing to report healthy on an empty read."
jq . status.json 2>/dev/null || cat status.json
exit 1
fi
# FAILED/CRASHED are the states that mean "this did not ship."
# Transient states (BUILDING, DEPLOYING, INITIALIZING, QUEUED) are not
# failures — a run that happens to land mid-deploy must stay quiet.
jq -r '
.data.environment.serviceInstances.edges[]
| .node
| "\(.serviceName)\t\(.latestDeployment.status // "NONE")\t\(.latestDeployment.createdAt // "-")"
' status.json | sort > all.tsv
echo "### Latest deployment per service" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
cat all.tsv >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
cat all.tsv
awk -F'\t' '$2=="FAILED" || $2=="CRASHED"' all.tsv > bad.tsv || true
if [ -s bad.tsv ]; then
{
echo "broken=true"
echo "detail<<EOF"
cat bad.tsv
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
echo "broken=false" >> "$GITHUB_OUTPUT"
fi
- name: Open or update the failure issue
if: steps.check.outputs.broken == 'true'
env:
GH_TOKEN: ${{ github.token }}
DETAIL: ${{ steps.check.outputs.detail }}
run: |
set -euo pipefail
BODY=$(printf '%s\n\n```\n%s\n```\n\n[Run](%s/%s/actions/runs/%s) · [Why this watchdog exists](%s/%s/blob/rebuild/turbo-rsbuild/context-v/issues/A-Failed-Deploy-Is-Silent-Nothing-Watches-Production-After-Merge.md)\n' \
"One or more Railway services report a failed latest deployment. The site may still be serving an older container, which is exactly why this is easy to miss." \
"$DETAIL" \
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}" \
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}")
EXISTING=$(gh issue list --label deploy-failure --state open --limit 1 --json number -q '.[0].number // empty')
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body "$BODY"
echo "Updated issue #$EXISTING"
else
gh issue create \
--title "Deploy failing on Railway (production)" \
--label deploy-failure \
--body "$BODY"
fi
- name: Close the failure issue on recovery
if: steps.check.outputs.broken == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
EXISTING=$(gh issue list --label deploy-failure --state open --limit 1 --json number -q '.[0].number // empty')
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" \
--comment "Every service reports a healthy latest deployment as of run ${GITHUB_RUN_ID}. Closing automatically."
fi
- name: Fail the run so the red mark is visible
if: steps.check.outputs.broken == 'true'
run: |
echo "::error::Failed Railway deployments:"
printf '%s\n' "${{ steps.check.outputs.detail }}"
exit 1