Repository navigation
439 lines (394 loc) · 17.4 KB
/
Copy pathmenu-sync.yml
File metadata and controls
439 lines (394 loc) · 17.4 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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# Keep the Providers menu of every provider microsite in step with the
# catalog stackql.io publishes (https://stackql.io/providers.json).
#
# The menu is baked into each site when it builds (index.js, sharedPlugin),
# so a site shows whatever the catalog said the day it last built. This
# workflow fetches the catalog, checks the menu every site actually serves,
# redeploys only the sites whose menu has drifted, and verifies each one came
# back right. Gates, in order, each a condition for the next:
#
# catalog plausibility guard against catalog/last-known-good.json
# preflight a real microsite built with THIS checkout of the chrome
# against the candidate must render the menu right
# check drift check of every live site; matching sites are left alone
# canary one drifted site redeployed and verified before the rest
# fanout the remaining drifted sites, four at a time, fail-fast
# finish per-site table; promote the candidate to last-known-good and
# close the menu-sync issue after a fully green run, otherwise
# open or update the issue and fail the run
#
# Scheduled and repository_dispatch runs sync; a manual run defaults to
# check (report only). README, "Propagation", has the operator's view;
# each script in scripts/menu-sync/ documents its own contract.
#
# Secrets: MICROSITE_DISPATCH_TOKEN (Actions: write on the stackql-registry
# provider repos) and NETLIFY_BUILD_HOOKS (JSON object slug -> build hook
# URL). The failure email of a scheduled run goes to the last person who
# committed this file.
name: menu-sync
on:
schedule:
- cron: '23 4 * * *'
workflow_dispatch:
inputs:
mode:
description: 'check = report only, sync = redeploy drifted sites'
type: choice
options: [check, sync]
default: check
only:
description: 'Comma-separated slugs; empty = every site in the catalog'
type: string
default: ''
force:
description: 'Redeploy even when the menu already matches, and skip the superset guard'
type: boolean
default: false
skip_preflight:
description: 'Skip the pre-flight build of a real microsite against the candidate'
type: boolean
default: false
catalog_url:
description: 'Catalog URL override, for testing the gates; default https://stackql.io/providers.json'
type: string
default: ''
repository_dispatch:
types: [catalog-updated]
permissions:
contents: read
concurrency:
group: menu-sync
cancel-in-progress: false
env:
CATALOG_URL: ${{ inputs.catalog_url || 'https://stackql.io/providers.json' }}
jobs:
catalog:
name: catalog
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
mode: ${{ steps.params.outputs.mode }}
only: ${{ steps.params.outputs.only }}
force: ${{ steps.params.outputs.force }}
catalog_overridden: ${{ steps.params.outputs.catalog_overridden }}
generated: ${{ steps.fetch.outputs.generated }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
# The run's parameters, resolved once here so every later job reads
# them from this job's outputs (the env context is not available in
# job-level if: conditions). Only a manual run can be check-only.
- name: Resolve run parameters
id: params
env:
EVENT: ${{ github.event_name }}
IN_MODE: ${{ inputs.mode }}
IN_ONLY: ${{ inputs.only }}
IN_FORCE: ${{ inputs.force }}
IN_CATALOG_URL: ${{ inputs.catalog_url }}
run: |
mode=sync
if [ "$EVENT" = workflow_dispatch ]; then mode="${IN_MODE:-check}"; fi
force=false
if [ "$IN_FORCE" = true ]; then force=true; fi
overridden=false
if [ -n "$IN_CATALOG_URL" ]; then overridden=true; fi
only="$(printf '%s' "$IN_ONLY" | tr -d '[:space:]')"
{
echo "mode=$mode"
echo "only=$only"
echo "force=$force"
echo "catalog_overridden=$overridden"
} >> "$GITHUB_OUTPUT"
echo "event=$EVENT mode=$mode only='$only' force=$force catalog=$CATALOG_URL"
- name: Fetch the candidate catalog
id: fetch
run: |
sep='?'
case "$CATALOG_URL" in *\?*) sep='&' ;; esac
curl -fsSL --retry 3 --retry-delay 5 \
-H 'Cache-Control: no-cache' -H 'Pragma: no-cache' -H 'Accept: application/json' \
-o candidate.json "${CATALOG_URL}${sep}cb=$(date +%s)"
generated="$(node -e 'console.log(JSON.parse(require("node:fs").readFileSync("candidate.json", "utf8")).generated || "")')"
echo "generated=$generated" >> "$GITHUB_OUTPUT"
echo "candidate catalog from $CATALOG_URL, generated ${generated:-n/a}"
- name: Plausibility guard
env:
FORCE: ${{ steps.params.outputs.force }}
run: |
args=()
if [ "$FORCE" = true ]; then args+=(--force); fi
node scripts/menu-sync/plausible.mjs candidate.json catalog/last-known-good.json "${args[@]}"
- name: Validate the site selection
env:
ONLY: ${{ steps.params.outputs.only }}
run: node scripts/menu-sync/check-fleet.mjs candidate.json --dry-run --only "$ONLY"
- uses: actions/upload-artifact@v7
with:
name: candidate
path: candidate.json
retention-days: 14
preflight:
name: preflight build
needs: catalog
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip_preflight) }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
path: chrome
- uses: actions/download-artifact@v8
with:
name: candidate
- name: Clone the fixture site
run: git clone --depth 1 --branch main https://github.com/stackql-registry/stackql-provider-typesafe.git site
# What `yarn vendor-config` does in a real build, with this checkout in
# place of a fresh clone of main, so the build exercises this commit.
- name: Vendor this checkout as the site's shared config
run: |
rm -rf site/website/.shared-config
rsync -a --exclude .git chrome/ site/website/.shared-config/
- uses: actions/setup-node@v7
with:
node-version: 22
cache: yarn
cache-dependency-path: site/website/yarn.lock
- name: Install the site's dependencies
run: yarn install --frozen-lockfile
working-directory: site/website
# `docusaurus build` directly: `yarn build` would run the site's
# prebuild hook and re-vendor main over this checkout.
- name: Build against the candidate catalog
run: STACKQL_PROVIDER_CATALOG="$GITHUB_WORKSPACE/candidate.json" yarn docusaurus build
working-directory: site/website
- name: The built menu must match the candidate
run: node chrome/scripts/menu-sync/compare.mjs candidate.json site/website/build/index.html
check:
name: drift check
needs: [catalog, preflight]
if: ${{ !cancelled() && needs.catalog.result == 'success' && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped') }}
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
drifted: ${{ steps.fleet.outputs.drifted }}
canary: ${{ steps.fleet.outputs.canary }}
fanout: ${{ steps.fleet.outputs.fanout }}
errors: ${{ steps.fleet.outputs.errors }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- uses: actions/download-artifact@v8
with:
name: candidate
# One job, eight fetches at a time: the whole fleet takes seconds. A
# drifted or unreachable site is a result here, not a failure; the
# finish job decides the run's colour.
- name: Compare every live menu with the candidate
id: fleet
env:
ONLY: ${{ needs.catalog.outputs.only }}
FORCE: ${{ needs.catalog.outputs.force }}
run: |
args=()
if [ "$FORCE" = true ]; then args+=(--force); fi
node scripts/menu-sync/check-fleet.mjs candidate.json --out results --concurrency 8 --only "$ONLY" "${args[@]}"
- uses: actions/upload-artifact@v7
if: always()
with:
name: result-check
path: results/*.json
retention-days: 14
canary:
name: canary ${{ needs.check.outputs.canary }}
needs: [catalog, preflight, check]
if: ${{ !cancelled() && needs.check.result == 'success' && needs.catalog.outputs.mode == 'sync' && needs.check.outputs.canary != '' }}
runs-on: ubuntu-latest
timeout-minutes: 75
env:
GH_TOKEN: ${{ secrets.MICROSITE_DISPATCH_TOKEN }}
NETLIFY_BUILD_HOOKS: ${{ secrets.NETLIFY_BUILD_HOOKS }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- uses: actions/download-artifact@v8
with:
name: candidate
- name: Redeploy and verify
env:
SLUG: ${{ needs.check.outputs.canary }}
run: bash scripts/menu-sync/sync-site.sh "$SLUG" candidate.json canary results
- uses: actions/upload-artifact@v7
if: always()
with:
name: result-canary
path: results/*.json
retention-days: 14
fanout:
name: sync ${{ matrix.site }}
needs: [catalog, check, canary]
if: ${{ !cancelled() && needs.canary.result == 'success' && needs.check.outputs.fanout != '[]' }}
runs-on: ubuntu-latest
timeout-minutes: 75
strategy:
matrix:
site: ${{ fromJSON(needs.check.outputs.fanout || '[]') }}
max-parallel: 4
fail-fast: true
env:
GH_TOKEN: ${{ secrets.MICROSITE_DISPATCH_TOKEN }}
NETLIFY_BUILD_HOOKS: ${{ secrets.NETLIFY_BUILD_HOOKS }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- uses: actions/download-artifact@v8
with:
name: candidate
- name: Redeploy and verify
env:
SLUG: ${{ matrix.site }}
run: bash scripts/menu-sync/sync-site.sh "$SLUG" candidate.json fanout results
- uses: actions/upload-artifact@v7
if: always()
with:
name: result-${{ matrix.site }}
path: results/*.json
retention-days: 14
finish:
name: finish
needs: [catalog, preflight, check, canary, fanout]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
issues: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.ref }}
- uses: actions/setup-node@v7
with:
node-version: 22
- uses: actions/download-artifact@v8
continue-on-error: true
with:
name: candidate
- uses: actions/download-artifact@v8
continue-on-error: true
with:
pattern: result-*
path: results
merge-multiple: true
- name: Per-site report
id: report
env:
MODE: ${{ needs.catalog.outputs.mode || 'sync' }}
JOBS: >-
{"catalog":"${{ needs.catalog.result }}",
"preflight":"${{ needs.preflight.result }}",
"check":"${{ needs.check.result }}",
"canary":"${{ needs.canary.result }}",
"fanout":"${{ needs.fanout.result }}"}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
catalog=candidate.json
if [ ! -f candidate.json ]; then catalog=catalog/last-known-good.json; fi
mkdir -p results
node scripts/menu-sync/report.mjs "$catalog" results --mode "$MODE" --jobs "$JOBS" --md report.md --run-url "$RUN_URL"
# The candidate becomes the baseline only when the whole fleet was
# verified against it in this run: every site ok or synced, no --only
# filter, the real catalog URL. The file is also what keeps the repo
# active for GitHub's 60-day scheduled-workflow rule, so a changed
# `generated` stamp alone is committed too; the message says which.
- name: Promote the candidate to last-known-good
if: ${{ steps.report.outputs.green == 'true' && steps.report.outputs.fleet_verified == 'true' && needs.catalog.outputs.only == '' && needs.catalog.outputs.catalog_overridden != 'true' }}
env:
BRANCH: ${{ github.ref_name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if cmp -s candidate.json catalog/last-known-good.json; then
echo "last-known-good already equals the candidate"
exit 0
fi
summary="$(node -e '
const fs = require("node:fs");
const slugs = (p) => JSON.parse(fs.readFileSync(p, "utf8")).categories.flatMap((c) => c.providers.map((x) => x.slug));
const before = new Set(slugs("catalog/last-known-good.json")), after = new Set(slugs("candidate.json"));
const added = [...after].filter((s) => !before.has(s)), removed = [...before].filter((s) => !after.has(s));
const parts = [];
if (added.length) parts.push(`added ${added.join(", ")}`);
if (removed.length) parts.push(`removed ${removed.join(", ")}`);
console.log(parts.length ? parts.join("; ") : "generated stamp only, same providers");
')"
generated="$(node -e 'console.log(JSON.parse(require("node:fs").readFileSync("candidate.json", "utf8")).generated || "n/a")')"
cp candidate.json catalog/last-known-good.json
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add catalog/last-known-good.json
git commit -m "catalog: last-known-good $generated" -m "$summary" -m "Fleet verified by menu-sync run $RUN_URL"
git push origin "HEAD:$BRANCH"
echo "committed last-known-good ($summary)"
# Same shape as the spec-drift jobs in the provider repos: one open
# issue per label, updated in place while the problem persists, closed
# by the first fully green run.
- name: Open, update or close the menu-sync issue
uses: actions/github-script@v9
env:
GREEN: ${{ steps.report.outputs.green }}
FLEET_VERIFIED: ${{ steps.report.outputs.fleet_verified }}
HEADLINE: ${{ steps.report.outputs.headline }}
FULL_FLEET: ${{ needs.catalog.outputs.only == '' && needs.catalog.outputs.catalog_overridden != 'true' }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const fs = require('fs');
const {owner, repo} = context.repo;
const label = 'menu-sync';
const title = 'menu-sync: provider menus out of step with the catalog';
const green = process.env.GREEN === 'true';
const fleetVerified = process.env.FLEET_VERIFIED === 'true';
const fullFleet = process.env.FULL_FLEET === 'true';
const runUrl = process.env.RUN_URL;
const headline = process.env.HEADLINE || '';
const {data: open} = await github.rest.issues.listForRepo({owner, repo, state: 'open', labels: label, per_page: 20});
if (green) {
if (!(fleetVerified && fullFleet)) return;
for (const issue of open) {
await github.rest.issues.createComment({owner, repo, issue_number: issue.number, body: `Resolved: the whole fleet verified against the catalog in [this run](${runUrl}).`});
await github.rest.issues.update({owner, repo, issue_number: issue.number, state: 'closed', state_reason: 'completed'});
core.info(`closed #${issue.number}`);
}
return;
}
try {
await github.rest.issues.getLabel({owner, repo, name: label});
} catch {
await github.rest.issues.createLabel({owner, repo, name: label, color: 'd93f0b', description: 'Opened by the menu-sync workflow'});
}
const table = fs.existsSync('report.md') ? fs.readFileSync('report.md', 'utf8') : '(no report)';
const body = `${table}\nOpened by the menu-sync workflow. It updates this issue while the problem persists and closes it after the next run in which the whole fleet verifies against the catalog. See README, "Propagation".`;
if (open.length) {
const issue = open[0];
await github.rest.issues.update({owner, repo, issue_number: issue.number, title, body});
await github.rest.issues.createComment({owner, repo, issue_number: issue.number, body: `Still failing: ${headline} ([run](${runUrl}))`});
core.info(`updated #${issue.number}`);
} else {
const {data: issue} = await github.rest.issues.create({owner, repo, title, body, labels: [label]});
core.info(`opened #${issue.number}`);
}
- name: Fail the run when it is not green
if: ${{ steps.report.outputs.green != 'true' }}
env:
HEADLINE: ${{ steps.report.outputs.headline }}
run: |
echo "::error title=menu-sync::$HEADLINE"
exit 1