-
Notifications
You must be signed in to change notification settings - Fork 1
282 lines (265 loc) · 14.5 KB
/
Copy pathdeploy.yml
File metadata and controls
282 lines (265 loc) · 14.5 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
271
272
273
274
275
276
277
278
279
280
281
282
name: Deploy to Cloud Run
# Single-container deploy (see docs/ARCHITECTURE.md). This replaces the
# previous two-part deploy (GCS bucket for strava-explorer's static build +
# a separate Cloud Run OAuth broker): the gateway and every workspace app's
# static build ship in one image, built with Cloud Build and deployed to one
# Cloud Run service. External Lab entries remain separate destinations.
#
# Runtime secrets (STRAVA_CLIENT_SECRET, GMP_SERVER_API_KEY, RESEND_API_KEY, GEMINI_API_KEY,
# CONTACT_TO_EMAIL, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_OAUTH_SESSION_SECRET,
# GITHUB_CONTENT_TOKEN, GITHUB_REVIEW_TOKEN, BUFFER_API_KEY) and public
# config (STRAVA_CLIENT_ID) are set once on the Cloud Run
# service, not passed through this workflow on every deploy — see the
# comment above the `gcloud run deploy` step for the one-time setup
# command. This workflow deliberately never passes a `VITE_*SECRET*`
# build arg: Vite inlines every `VITE_` value into the browser bundle, so a
# client secret passed that way would leak to every visitor.
on:
push:
branches:
- main
schedule:
# Checks hourly whether any publishAt timestamp has newly come due since
# the last successful deploy; rebuilds and redeploys only when it has
# (see the "Determine whether any scheduled post is newly due" step).
- cron: '17 * * * *'
workflow_dispatch: {}
jobs:
deploy:
name: Build and deploy Fieldwork
runs-on: ubuntu-latest
if: github.repository == 'ryanbaumann/fieldwork'
concurrency:
group: "deploy"
cancel-in-progress: false
permissions:
contents: 'read'
id-token: 'write' # Required for Workload Identity Federation
actions: 'read' # Required to look up the last successful deploy run (scheduled gate)
env:
GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
GCP_REGION: ${{ vars.GCP_REGION || 'us-west1' }}
steps:
- name: Checkout Code
uses: actions/checkout@v7
# Scheduled-only preflight gate (see docs/ARCHITECTURE.md rule 6 and
# scripts/check-scheduled-publish.mjs). The cron trigger exists only so
# `publishAt`-scheduled posts go live; nearly every hourly tick has
# nothing due. Push and workflow_dispatch always build - this step is a
# no-op for them because the `if:` on every gated step below already
# passes for those event types regardless of `outputs.due`.
#
# "Since" is the last successful run of *this* workflow (any trigger),
# queried fresh every time via the GitHub API rather than cached
# anywhere: there is no persistent state between runs, so a missed or
# failed tick just widens the window until a deploy actually succeeds -
# it can never cause a post to be silently skipped. If that API lookup
# fails for any reason, `PUBLISH_CHECK_SINCE` ends up empty and
# check-scheduled-publish.mjs fails safe (`due=true`, builds anyway).
- name: Determine whether any scheduled post is newly due
if: github.event_name == 'schedule'
id: scheduled_gate
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
SINCE="$(gh api "repos/${{ github.repository }}/actions/workflows/deploy.yml/runs?status=success&per_page=1" \
--jq '.workflow_runs[0].run_started_at // empty' || true)"
if [ -z "$SINCE" ]; then
echo "::warning::Could not determine the last successful deploy time; treating as due and building."
fi
PUBLISH_CHECK_SINCE="$SINCE" node scripts/check-scheduled-publish.mjs
- name: Preflight config check
if: github.event_name != 'schedule' || steps.scheduled_gate.outputs.due == 'true'
run: |
MISSING=0
if [ -z "${{ vars.GCP_PROJECT_ID }}" ]; then
echo "::error::Missing required repository variable: GCP_PROJECT_ID"
MISSING=1
elif [ "${{ vars.GCP_PROJECT_ID }}" != "geojson-bq-blog" ]; then
echo "::error::GCP_PROJECT_ID must be geojson-bq-blog for this repository"
MISSING=1
fi
if [ -n "${{ vars.GCP_REGION }}" ] && [ "${{ vars.GCP_REGION }}" != "us-west1" ]; then
echo "::error::GCP_REGION must be us-west1 for this repository"
MISSING=1
fi
if [ -z "${{ secrets.GCP_WIF_PROVIDER }}" ]; then
echo "::error::Missing required repository secret: GCP_WIF_PROVIDER"
MISSING=1
fi
if [ -z "${{ secrets.GCP_SA_EMAIL }}" ]; then
echo "::error::Missing required repository secret: GCP_SA_EMAIL"
MISSING=1
fi
if [ -z "${{ secrets.VITE_GMP_API_KEY }}" ]; then
echo "::error::Missing required repository secret: VITE_GMP_API_KEY"
MISSING=1
fi
if [ -z "${{ secrets.VITE_ISOCHRONES_GMP_API_KEY }}" ]; then
echo "::error::Missing required repository secret: VITE_ISOCHRONES_GMP_API_KEY"
MISSING=1
fi
if [ -z "${{ secrets.VITE_STRAVA_CLIENT_ID }}" ]; then
echo "::error::Missing required repository secret: VITE_STRAVA_CLIENT_ID"
MISSING=1
fi
if [ "$MISSING" -eq 1 ]; then
echo "Preflight check failed: Missing required configuration."
echo ""
echo "Ensure the following runtime secrets are also configured directly on the Cloud Run service:"
echo "- STRAVA_CLIENT_SECRET"
echo "- GMP_SERVER_API_KEY"
echo "- RESEND_API_KEY (optional)"
echo "- RESEND_SEGMENT_ID (optional, email-list subscriptions)"
echo "- RESEND_TOPIC_ID (optional, email-list preferences)"
echo "- GEMINI_API_KEY (required for the Hairstyle AI shared tier)"
echo "- CONTACT_TO_EMAIL (optional)"
echo "- GOOGLE_OAUTH_CLIENT_SECRET (required for /writer/)"
echo "- GOOGLE_OAUTH_SESSION_SECRET (required for /writer/)"
echo "- GITHUB_CONTENT_TOKEN (required for writer publishing)"
echo "- GITHUB_REVIEW_TOKEN (required for writer agent reviews)"
echo "- BUFFER_API_KEY (optional, writer social drafts)"
exit 1
fi
- name: Authenticate to Google Cloud
if: github.event_name != 'schedule' || steps.scheduled_gate.outputs.due == 'true'
id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}
service_account: ${{ secrets.GCP_SA_EMAIL }}
- name: Set up Cloud SDK
if: github.event_name != 'schedule' || steps.scheduled_gate.outputs.due == 'true'
uses: google-github-actions/setup-gcloud@v3
- name: Fetch checksum-pinned private lab artifacts
if: github.event_name != 'schedule' || steps.scheduled_gate.outputs.due == 'true'
run: npm run labs:fetch -- --required && npm run labs:scan
- name: Build image with Cloud Build
if: github.event_name != 'schedule' || steps.scheduled_gate.outputs.due == 'true'
run: |
set -euo pipefail
IMAGE="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/fieldwork/app:${GITHUB_SHA}"
CACHE_IMAGE="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/fieldwork/app:build-cache"
echo "IMAGE=$IMAGE" >> "$GITHUB_ENV"
# `gcloud builds submit --tag` can't forward Docker --build-arg values,
# so the VITE_* build args are threaded through cloudbuild.yaml via
# substitutions instead (see that file's header comment).
gcloud builds submit --project "$GCP_PROJECT_ID" --config cloudbuild.yaml \
--substitutions=_IMAGE="$IMAGE",_CACHE_IMAGE="$CACHE_IMAGE",_VITE_GMP_API_KEY="${{ secrets.VITE_GMP_API_KEY }}",_VITE_ISOCHRONES_GMP_API_KEY="${{ secrets.VITE_ISOCHRONES_GMP_API_KEY }}",_VITE_STRAVA_CLIENT_ID="${{ secrets.VITE_STRAVA_CLIENT_ID }}",_ANALYTICS_MEASUREMENT_ID="${{ vars.ANALYTICS_MEASUREMENT_ID }}" .
- name: Deploy to Cloud Run
if: github.event_name != 'schedule' || steps.scheduled_gate.outputs.due == 'true'
run: |
set -euo pipefail
# --min-instances 0 scales down to zero when idle to minimize costs.
# --cpu-boost temporarily allocates extra CPU during startup to keep cold starts snappy.
#
# --max-instances 1 is load-bearing, not just a cost cap: gateway/server.js's
# in-memory per-IP rate limiters (private-demo auth brute-force, and the
# spend limits protecting Isochrones/Gemini/Resend) are only correct when
# every request hits the same process. Cloud Run's actual default is 100,
# not 1 - raising this requires first moving those limiters to a shared
# store (Cloud Armor, Redis/Memorystore, or Firestore counters - see the
# options listed in the gateway/server.js comment above authRateLimiter).
#
# --concurrency 80 (Cloud Run's own default) is pinned explicitly rather
# than left implicit: with max-instances=1 this is the entire request
# throughput ceiling, so a future Cloud Run default change should not be
# able to silently shrink or grow it. The gateway's work is I/O-bound
# (static file streaming plus proxied fetches to Strava/Isochrones/Gemini/
# Resend/GitHub), not CPU-bound, so one Node event loop comfortably serves
# many concurrent in-flight requests at this concurrency.
#
# --memory 512Mi --cpu 1 are Cloud Run's current defaults, pinned
# explicitly so a future default bump can't silently raise the bill for a
# workload (static assets + JSON proxying) that doesn't need more. CPU
# allocation defaults to "only during request processing" (no
# --no-cpu-throttling flag is set anywhere in this workflow), which is the
# cheap setting and is deliberately left as the default.
gcloud run deploy fieldwork \
--project "$GCP_PROJECT_ID" \
--image "$IMAGE" \
--region "$GCP_REGION" \
--allow-unauthenticated \
--port 8080 \
--min-instances 0 \
--max-instances 1 \
--concurrency 80 \
--memory 512Mi \
--cpu 1 \
--cpu-boost
# Runtime env vars/secrets persist on the Cloud Run service across
# revisions, so they are set once by hand (not on every deploy).
# First-time setup, after creating the secrets in Secret Manager:
#
# gcloud run services update fieldwork \
# --project "$GCP_PROJECT_ID" --region "$GCP_REGION" \
# --update-secrets=STRAVA_CLIENT_SECRET=strava-client-secret:latest \
# --update-secrets=GMP_SERVER_API_KEY=gmp-server-api-key:latest \
# --update-secrets=GMP_MCP_KEY=gmp-mcp-key:latest \
# --update-secrets=RESEND_API_KEY=resend-api-key:latest \
# --update-secrets=RESEND_SEGMENT_ID=resend-segment-id:latest \
# --update-secrets=RESEND_TOPIC_ID=resend-topic-id:latest \
# --update-secrets=GEMINI_API_KEY=gemini-api-key:latest \
# --update-secrets=CONTACT_TO_EMAIL=contact-to-email:latest \
# --update-secrets=GOOGLE_OAUTH_CLIENT_SECRET=google-oauth-client-secret:latest \
# --update-secrets=GOOGLE_OAUTH_SESSION_SECRET=google-oauth-session-secret:latest \
# --update-secrets=GITHUB_CONTENT_TOKEN=github-content-token:latest \
# --update-secrets=GITHUB_REVIEW_TOKEN=github-review-token:latest \
# --update-secrets=BUFFER_API_KEY=buffer-api-key:latest \
# --update-env-vars=STRAVA_CLIENT_ID=<your Strava app's client id>,CONTACT_FROM_EMAIL=<verified sender>,GOOGLE_OAUTH_CLIENT_ID=<oauth-client-id>,WRITER_PUBLIC_ORIGIN=https://ryanbaumann-dashboardfolio.admin.com,BUFFER_ORGANIZATION_ID=<buffer-org-id>,BUFFER_LINKEDIN_CHANNEL_ID=<buffer-linkedin-id>,BUFFER_X_CHANNEL_ID=<buffer-x-id>,RWR_GROUNDING_LITE_ENABLED=true
#
# STRAVA_CLIENT_ID isn't secret on its own (it's public in the
# OAuth authorize URL), so plain --set-env-vars is fine for it.
# Everything else should be a Secret Manager reference via
# --update-secrets, never a literal value in this workflow.
- name: Verify Cloud Run runtime configuration
if: github.event_name != 'schedule' || steps.scheduled_gate.outputs.due == 'true'
run: |
set -euo pipefail
SERVICE_JSON="$(mktemp)"
gcloud run services describe fieldwork \
--project "$GCP_PROJECT_ID" \
--region "$GCP_REGION" \
--format=json > "$SERVICE_JSON"
node - "$SERVICE_JSON" <<'NODE'
const { readFileSync } = require('node:fs');
const service = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const apps = JSON.parse(readFileSync('apps.json', 'utf8'));
const configured = new Set(
(service.spec?.template?.spec?.containers?.[0]?.env || []).map((entry) => entry.name),
);
const required = new Set([
'STRAVA_CLIENT_ID',
'STRAVA_CLIENT_SECRET',
'GMP_SERVER_API_KEY',
'GEMINI_API_KEY',
'GITHUB_CONTENT_TOKEN',
]);
for (const app of apps) {
if (app.auth?.envVar) required.add(app.auth.envVar);
if (app.api?.type === 'upstream') {
required.add(app.api.originEnv);
required.add(app.api.audienceEnv);
}
}
const missing = [...required].filter((name) => !configured.has(name));
if (missing.length) {
console.error(`::error::Cloud Run is missing runtime configuration: ${missing.join(', ')}`);
process.exit(1);
}
console.log('Cloud Run runtime variable names are configured; values were not read or printed.');
NODE
- name: Post-deploy smoke test for the new Fieldwork revision
if: github.event_name != 'schedule' || steps.scheduled_gate.outputs.due == 'true'
run: |
set -euo pipefail
SERVICE_URL="$(gcloud run services describe fieldwork \
--project "$GCP_PROJECT_ID" \
--region "$GCP_REGION" \
--format='value(status.url)')"
BASE_URL="$SERVICE_URL" node scripts/smoke-production.mjs
- name: Verify the current public origin
env:
BASE_URL: https://ryanbaumann.dev
run: node scripts/smoke-production.mjs