-
Notifications
You must be signed in to change notification settings - Fork 269
646 lines (628 loc) · 33.6 KB
/
Copy pathreview-existing-content.yml
File metadata and controls
646 lines (628 loc) · 33.6 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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
name: "Scheduled jobs: Review existing content (dispatcher)"
# Daily (workdays) automated review of EXISTING docs pages — the
# counterpart to the PR-triggered docs review, for content that nobody is
# currently editing. This workflow is the DISPATCHER: the selection script
# picks the day's articles by weighted fair queuing — score = importance
# (strategic tier x traffic) x staleness (days since the page's effective last
# review) — and this workflow then fans out one
# content-review-article.yml run per selected article. The importance term is
# strategic tier x traffic, nudged by bounded boost-only reader signals (Search
# Console impressions/CTR, feedback-widget votes) when their export exists.
# Each worker reviews
# exactly one article and, if it finds a fix, opens a ready PR (draft only on
# lint failure), firing the normal triage -> review chain. The publish gate
# classes each PR from its verdict's applied[] categories: deterministic-class
# PRs (links, Vale-named fixes, frontmatter; small churn) arm GitHub
# auto-merge — master requires an approving review + the build check, so a
# human approval is still what merges them; judgment-class PRs (claim
# corrections, readthrough repairs, retirements) open UN-armed and merge only
# when the PR-review sweep arms them post-approval or a human merges.
#
# THREE LANES live in this dispatcher, all sharing the weekday cron, the
# holiday gate, and the CONTENT_REVIEW_COUNT kill switch: the fix lane (below),
# the glow-up lane, and the report-only lane (pulumi/docs#20996) that
# fact-checks the generated trees the other two must never edit. Each has its
# own count variable; see the step comments for the report lane's default-off
# rationale.
#
# SINGLE SWITCH (default-on) — the CONTENT_REVIEW_COUNT repo variable is both
# the on/off and the cadence knob:
# unset -> on, 3 articles/run (the default)
# '0' -> off: the whole job is skipped (no runner, no spend)
# 'N' -> on, N articles/run
# Set it from Settings -> Variables to retune or pause with no PR. The selector
# also no-ops cleanly on any count<=0, so a stray '00'/'-1' still does no work.
# workflow_dispatch always runs regardless (the testing path; see the
# dry_run / count / paths inputs).
#
# The job gate below wraps the variable in format('{0}', ...) — do not
# "simplify" it back to a bare `vars.X != '0'`. An unset variable is null, and
# GitHub coerces a Null-vs-String comparison to numbers (null -> 0, '0' -> 0),
# so the bare form evaluates 0 != 0 = FALSE and unset silently means OFF —
# inverting the default documented above. format() forces a string, making the
# unset case a true '' != '0' string compare. This is not hypothetical: it kept
# blog-review-index and claims-reverify from ever running on schedule.
#
# DEGRADATION HEALTH (issue #20078 §3.4): three inputs degrade gracefully but
# used to do so silently — the traffic snapshot (selection falls back to
# tier-only), pulumi/pulumi-service access (workers' screenshot lane 2 goes
# unverifiable), and the holiday feed (gate fails open). The health steps at
# the end of the review job track each signal's state in the ledger bucket
# under health/ and post a one-line-per-signal note to #docs-ops once a signal
# has been degraded for a week (see scripts/content-review/signal-health.py).
# claims-reverify.yml contributes a fourth signal (`reverify`) to the same
# state object; this workflow neither observes nor overwrites it.
on:
schedule:
# Weekdays at 2:00PM UTC (one hour before check-links, so the two
# bot-PR producers don't contend).
- cron: '0 14 * * 1-5'
workflow_dispatch:
inputs:
count:
description: 'Number of articles to review'
required: false
default: '3'
dry_run:
description: 'Selection only — no Claude, no PRs, no spend'
type: boolean
required: false
default: false
paths:
description: 'Comma-separated content paths (bypasses scoring; for testing)'
required: false
default: ''
permissions:
contents: write
pull-requests: write
issues: write
id-token: write # Required for ESC OIDC auth + AWS OIDC role
actions: write # Required to dispatch content-review-article.yml per article
jobs:
# Optional: skip the scheduled run on company holidays. Reads a BambooHR
# "Company Holidays" ICS feed from the BAMBOOHR_HOLIDAY_ICS_URL repo VARIABLE
# (a per-user feed token, treated as a temporary convenience — not a permanent
# shared resource). FAILS OPEN at every step: variable unset, feed 404/fetch
# failure, or parse error all yield is_holiday=false, so the dispatcher runs —
# a missing or stale feed never silently halts the pipeline. The URL is never
# echoed, so the token stays out of the logs. (Manual workflow_dispatch ignores
# this; see the `review` gate.) Always runs so `review`'s `needs` is satisfiable.
holiday-check:
name: Company-holiday check
runs-on: ubuntu-latest
# feed_status feeds the degradation-health lane at the end of the review
# job: unconfigured (an explicit off-switch, never alarmed) / fetch_failed /
# empty (feed fetched but parsed to no usable holiday events — a rotated
# token serving an error page looks exactly like a quiet day otherwise) /
# ok. The holiday DECISION below is unchanged and still fails open.
outputs:
is_holiday: ${{ steps.check.outputs.is_holiday }}
feed_status: ${{ steps.check.outputs.feed_status }}
steps:
- uses: actions/checkout@v7
- id: check
env:
ICS_URL: ${{ vars.BAMBOOHR_HOLIDAY_ICS_URL }}
run: |
if [ -z "$ICS_URL" ]; then
echo "BAMBOOHR_HOLIDAY_ICS_URL not set; not skipping for holidays"
echo "is_holiday=false" >> "$GITHUB_OUTPUT"
echo "feed_status=unconfigured" >> "$GITHUB_OUTPUT"; exit 0
fi
if ! curl -fsSL --max-time 20 "$ICS_URL" -o holidays.ics; then
echo "::warning::holiday feed unavailable (404/fetch failure); failing open (dispatcher will run)"
echo "is_holiday=false" >> "$GITHUB_OUTPUT"
echo "feed_status=fetch_failed" >> "$GITHUB_OUTPUT"; exit 0
fi
if python3 scripts/content-review/is-holiday.py --ics holidays.ics --tz America/Chicago; then
echo "is_holiday=true" >> "$GITHUB_OUTPUT"
else
echo "is_holiday=false" >> "$GITHUB_OUTPUT"
fi
# Health probe, separate from the decision: does the feed look alive
# (any matching holiday event within a year)?
if python3 scripts/content-review/is-holiday.py --ics holidays.ics --feed-check; then
echo "feed_status=ok" >> "$GITHUB_OUTPUT"
else
echo "::warning::holiday feed parsed to zero usable holiday events (empty/garbage feed?)"
echo "feed_status=empty" >> "$GITHUB_OUTPUT"
fi
review:
name: Review existing content
needs: holiday-check
runs-on: ubuntu-latest
# PULUMI_STACK_NAME is an environment-scoped variable, so the job must
# select the environment to resolve it — otherwise `Resolve ledger bucket`
# runs with --stack "" and the ledger fetch (review history) is silently skipped.
environment: production
# Hard cost ceiling: selection + up to `count` article reviews fit well
# inside this; a hung run dies rather than burning API budget.
timeout-minutes: 60
if: >-
github.event_name == 'workflow_dispatch' ||
(format('{0}', vars.CONTENT_REVIEW_COUNT) != '0' && needs.holiday-check.outputs.is_holiday != 'true')
steps:
# ESC runs before checkout so the bot token can authenticate the
# checkout — the PRs opened by claude-code-action later then go out
# as pulumi-bot rather than github-actions[bot].
- name: Fetch secrets from ESC
id: esc-secrets
uses: pulumi/esc-action@v3
- name: Checkout repository
uses: actions/checkout@v7
with:
token: ${{ steps.esc-secrets.outputs.PULUMI_BOT_TOKEN }}
# Selection reads git history (staleness clock: creation date and
# newest non-bot edit per page), so a shallow clone won't do — but
# it only needs commits and trees (`git log`), never historical
# blobs, so a blobless partial clone keeps the full-history clone
# from eating most of this job's hour budget.
fetch-depth: 0
filter: blob:none
# Lane-2 health probe: the per-article worker's screenshot lane 2
# verifies UI strings against the Pulumi Cloud Console source — the
# cmd/console2 app in the private pulumi/pulumi-service repo — using
# PULUMI_BOT_TOKEN from the SAME ESC environment this job holds — so
# probing access here, once per run, is a deterministic proxy for whether
# every worker dispatched today will silently degrade lane 2 to
# "unverifiable". Keep the two token sources coupled or this probe lies.
# Never fails; feeds the degradation-health steps at the end of the job.
- name: Probe pulumi/pulumi-service access (lane-2 health)
id: console-probe
env:
GH_TOKEN: ${{ steps.esc-secrets.outputs.PULUMI_BOT_TOKEN }}
run: |
if gh repo view pulumi/pulumi-service --json name -q .name >/dev/null 2>&1; then
echo "status=ok" >> "$GITHUB_OUTPUT"
else
echo "::warning::pulumi/pulumi-service not accessible to the bot token; screenshot lane 2 will be unverifiable"
echo "status=degraded" >> "$GITHUB_OUTPUT"
fi
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.x'
cache: 'yarn'
cache-dependency-path: |
yarn.lock
infrastructure/yarn.lock
theme/yarn.lock
theme/stencil/yarn.lock
- name: Install Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.157.0'
extended: true
# Vale (and other mise-pinned tools) for the skill's whole-file
# prose-lint pre-step.
- name: Install mise-managed tools
uses: jdx/mise-action@v4
with:
cache: true
- name: Install Python deps
run: python3 -m pip install --quiet pyyaml
# Traffic snapshot and review ledger both live in S3, and both resolve
# their locations from Pulumi stack outputs rather than hand-maintained
# repo variables (a hardcoded bucket name was the original drift bug).
# Every step here degrades gracefully: a missing AWS role, an unresolved
# stack output, or an absent object all leave selection running on
# strategic tier + review age alone.
- name: Configure AWS credentials
continue-on-error: true
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::388588623842:role/ContinuousDelivery
role-session-name: content-review
aws-region: us-west-2
- name: Install Pulumi CLI
uses: pulumi/actions@v7
# The ledger bucket is a resource of OUR Pulumi stack, so resolve its name
# from the stack output — the same pattern schedule-social.yml uses for
# socialStateBucketName. Can't drift if the (auto-named) bucket is replaced.
- name: Resolve ledger bucket
id: ledger-bucket
continue-on-error: true
env:
PULUMI_ACCESS_TOKEN: ${{ steps.esc-secrets.outputs.PULUMI_ACCESS_TOKEN }}
run: |
BUCKET=$(pulumi -C infrastructure stack output contentReviewLedgerBucketName \
--stack "${{ vars.PULUMI_STACK_NAME }}" 2>/dev/null || true)
if [ -n "$BUCKET" ]; then
echo "uri=s3://$BUCKET/ledger/" >> "$GITHUB_OUTPUT"
# Degradation-health state lives in the same bucket, own prefix.
echo "health_uri=s3://$BUCKET/health/" >> "$GITHUB_OUTPUT"
# Structured per-page findings (record-page-findings.py), synced so
# the glow-up selector can hand each article its own record.
echo "findings_uri=s3://$BUCKET/findings/" >> "$GITHUB_OUTPUT"
# Raw bucket name, handed to each worker via queue_json so a
# per-worker `pulumi stack output` flake can't silently drop the
# ledger write (the flake that caused duplicate re-reviews).
echo "bucket=$BUCKET" >> "$GITHUB_OUTPUT"
else
echo "ledger bucket output unavailable; selection proceeds on git staleness alone"
fi
# The docs-traffic export (pageviews per /docs/ path over the trailing six
# months) is produced weekly by the data team; the latest-object S3 URI is
# published as an output on their Airflow stack, which we read by stack
# reference (pulumi/data#865). The stack is foreign, so no -C / local
# project — the fully-qualified --stack resolves it from the service.
- name: Resolve traffic snapshot URI
id: traffic-uri
continue-on-error: true
env:
PULUMI_ACCESS_TOKEN: ${{ steps.esc-secrets.outputs.PULUMI_ACCESS_TOKEN }}
run: |
URI=$(pulumi stack output docsTrafficPageviewsLatestS3Uri \
--stack pulumi/dwh-workflows-orchestrate-airflow/production 2>/dev/null || true)
if [ -n "$URI" ]; then
# The URI is an s3:// path, not a secret — log it so a glance at the
# run confirms the cross-stack read resolved (silent success would be
# indistinguishable from a skip).
echo "resolved docs-traffic export URI: $URI"
echo "uri=$URI" >> "$GITHUB_OUTPUT"
else
echo "docs-traffic export output unavailable; selection proceeds on tier + age alone"
fi
- name: Fetch traffic snapshot from S3
if: steps.traffic-uri.outputs.uri != ''
continue-on-error: true
run: |
if aws s3 cp "${{ steps.traffic-uri.outputs.uri }}" .traffic-snapshot --quiet; then
echo "fetched traffic snapshot: $(wc -c < .traffic-snapshot) bytes"
else
echo "traffic snapshot unavailable; selection proceeds without it"
fi
# The reader-signals export (Search Console impressions/CTR, docs
# feedback-widget vote counts, real-404 hits per URL) follows the exact
# same pattern as the traffic snapshot above: produced weekly by the data
# team, latest-object URI published on their Airflow stack. The combined
# envelope (docsReaderSignalsLatestS3Uri) is not exported yet — until it
# is, fall back to the GSC-only export the data team already ships weekly
# (docsTrafficGscLatestS3Uri, pulumi/data#865 phase 2);
# load_reader_signals() accepts its bare shape directly. With neither
# output published, this resolve logs the skip and selection scores
# exactly as before (pulumi/docs#20078 item 4.5).
- name: Resolve reader-signals URI
id: signals-uri
continue-on-error: true
env:
PULUMI_ACCESS_TOKEN: ${{ steps.esc-secrets.outputs.PULUMI_ACCESS_TOKEN }}
run: |
URI=$(pulumi stack output docsReaderSignalsLatestS3Uri \
--stack pulumi/dwh-workflows-orchestrate-airflow/production 2>/dev/null || true)
if [ -z "$URI" ]; then
URI=$(pulumi stack output docsTrafficGscLatestS3Uri \
--stack pulumi/dwh-workflows-orchestrate-airflow/production 2>/dev/null || true)
if [ -n "$URI" ]; then
echo "reader-signals envelope not published; falling back to the GSC-only export"
fi
fi
if [ -n "$URI" ]; then
echo "resolved reader-signals export URI: $URI"
echo "uri=$URI" >> "$GITHUB_OUTPUT"
else
echo "reader-signals export not yet published; selection proceeds without reader signals"
fi
- name: Fetch reader-signals snapshot from S3
if: steps.signals-uri.outputs.uri != ''
continue-on-error: true
run: |
if aws s3 cp "${{ steps.signals-uri.outputs.uri }}" .reader-signals.json --quiet; then
echo "fetched reader-signals snapshot: $(wc -c < .reader-signals.json) bytes"
else
echo "reader-signals snapshot unavailable; selection proceeds without it"
fi
# The review ledger (one JSON object per page, key = slug) lives in S3, not
# the repo — so the daily bookkeeping never round-trips through a PR. Sync
# it down to a local cache that select-articles.py reads via --ledger-dir.
# Degrades gracefully like the traffic fetch: an empty/absent ledger just
# means every page's staleness clock falls back to its git history
# (creation date / newest non-bot edit), with no prior reviews to apply.
- name: Fetch review ledger from S3
if: steps.ledger-bucket.outputs.uri != ''
continue-on-error: true
run: |
mkdir -p .ledger-cache
aws s3 sync "${{ steps.ledger-bucket.outputs.uri }}" .ledger-cache/ --quiet \
|| echo "review ledger unavailable; selection proceeds on git staleness alone"
# Structured findings live in a sibling prefix; the glow-up selector
# hands each selected article its own record, because the worker that
# builds the backlog has no AWS credentials.
mkdir -p .findings-cache
aws s3 sync "${{ steps.ledger-bucket.outputs.findings_uri }}" .findings-cache/ --quiet \
|| echo "findings records unavailable; glow-up falls back to the PR-body scrape"
# Deterministic selection: the model never chooses what to review.
# Writes has_articles= / halted= to $GITHUB_OUTPUT for the gates below.
- name: Select articles
id: select
env:
GH_TOKEN: ${{ steps.esc-secrets.outputs.PULUMI_BOT_TOKEN }}
run: |
# Count precedence: a manual workflow_dispatch input wins; otherwise the
# scheduled run uses the CONTENT_REVIEW_COUNT repo variable; otherwise 3.
ARGS=(--count "${{ github.event.inputs.count || vars.CONTENT_REVIEW_COUNT || '3' }}" --out .content-review-queue.json)
if [ -d .ledger-cache ]; then
ARGS+=(--ledger-dir .ledger-cache)
fi
if [ -f .traffic-snapshot ]; then
ARGS+=(--traffic-file .traffic-snapshot)
fi
if [ -f .reader-signals.json ]; then
ARGS+=(--signals-file .reader-signals.json)
fi
if [ -n "${{ github.event.inputs.paths }}" ]; then
ARGS+=(--paths "${{ github.event.inputs.paths }}")
fi
python3 scripts/content-review/select-articles.py "${ARGS[@]}"
echo "--- queue ---"
cat .content-review-queue.json
# Fan out one content-review-article.yml run per selected article. Each
# worker run reviews its single article, opens one ready PR, and
# force-dispatches the docs review over it. On dry runs and quiet/halted
# days this is skipped, so those runs cost no API spend.
- name: Dispatch per-article workers
if: steps.select.outputs.has_articles == 'true' && github.event.inputs.dry_run != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Hand each worker its OWN single-article queue, carrying the
# selection facts this dispatcher computed (traffic, score, tier,
# last-reviewed). The worker renders the PR's "Why this page" block
# from these deterministically rather than re-deriving a traffic-blind
# queue and narrating it — facts composed by code, the same split the
# pre-merge review uses. `path`/`lane` are still passed for the
# concurrency group and the --paths fallback.
TRAFFIC=$(jq -c '.traffic' .content-review-queue.json)
SIGNALS=$(jq -c '.reader_signals // null' .content-review-queue.json)
GEN=$(jq -r '.generated' .content-review-queue.json)
# ledger_bucket rides the queue so the worker's publish job doesn't
# depend on its own (flaky) stack-output resolve. The publish job
# reads it from the PRE-MODEL guardrail snapshot copy of the queue,
# never the model-writable handoff copy. Keep this jq in sync with
# the glow-up dispatch block below, which builds the same shape.
LB="${{ steps.ledger-bucket.outputs.bucket }}"
jq -c '.articles[]' .content-review-queue.json | while read -r a; do
P=$(echo "$a" | jq -r '.path')
L=$(echo "$a" | jq -r '.lane')
Q=$(jq -nc --argjson art "$a" --argjson traffic "$TRAFFIC" \
--argjson signals "$SIGNALS" --arg gen "$GEN" --arg lb "$LB" \
'{generated: $gen, count: 1, traffic: $traffic, reader_signals: $signals,
ledger_bucket: (if $lb == "" then null else $lb end), articles: [$art]}')
echo "dispatching worker for $P (lane=$L)"
gh workflow run content-review-article.yml \
--repo "${{ github.repository }}" \
-f path="$P" \
-f lane="$L" \
-f queue_json="$Q" \
|| echo "::warning::worker dispatch failed for $P"
done
# ---- Glow-up lane (one whole-page rehab per working day) ----
# Selection is deterministic like the fix lane's, but over the LEDGER:
# the page with the largest accumulated backlog of banked judgment
# findings (skipped_findings, clarity_flag, low-CTR flag) wins. The
# worker runs in mode=glowup and its PR never arms auto-merge — the
# PR-review sweep assigns Cam/Josh as reviewers. Backlog cap: at
# GLOWUP_MAX_OPEN_PRS (10) open glow-up PRs the selector halts
# (max_open_glowup_prs) and the day's slots are skipped.
#
# GLOWUP_COUNT repo variable: unset -> 1/working-day (the default; set
# to '2' in pulumi/docs on 2026-08-19),
# '0' -> off, 'N' -> N/day. Same format('{0}', ...) null-coercion trick
# as the job gate above — a bare `vars.X != '0'` makes unset mean OFF.
# Living inside this job, the lane inherits the weekday cron, the
# holiday gate, and the CONTENT_REVIEW_COUNT=0 kill switch (turning the
# fix lane off darkens glow-ups too — intended).
- name: Select and dispatch glow-up
if: >-
steps.select.outcome == 'success' &&
github.event.inputs.dry_run != 'true' &&
format('{0}', vars.GLOWUP_COUNT) != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ARGS=(--count "${{ vars.GLOWUP_COUNT || '1' }}" --out .glowup-queue.json)
if [ -d .ledger-cache ]; then
ARGS+=(--ledger-dir .ledger-cache)
fi
# Each selected article carries its structured findings record into
# the worker: build-glowup-backlog.py runs unprivileged and cannot
# read S3 itself (see record-page-findings.py).
if [ -d .findings-cache ]; then
ARGS+=(--findings-dir .findings-cache)
fi
if [ -f .traffic-snapshot ]; then
ARGS+=(--traffic-file .traffic-snapshot)
fi
python3 scripts/content-review/select-glowup.py "${ARGS[@]}"
echo "--- glow-up queue ---"
cat .glowup-queue.json
HALTED=$(jq -r '.halted // empty' .glowup-queue.json)
if [ -n "$HALTED" ]; then
echo "::warning::glow-up selection halted: $HALTED (no glow-up dispatched)"
exit 0
fi
# Same single-article queue_json shape as the fix-lane dispatch
# above — keep the two jq fragments in sync, ledger_bucket included
# (see that step's comment for why it must ride the queue).
TRAFFIC=$(jq -c '.traffic' .glowup-queue.json)
GEN=$(jq -r '.generated' .glowup-queue.json)
LB="${{ steps.ledger-bucket.outputs.bucket }}"
jq -c '.articles[]' .glowup-queue.json | while read -r a; do
P=$(echo "$a" | jq -r '.path')
Q=$(jq -nc --argjson art "$a" --argjson traffic "$TRAFFIC" \
--arg gen "$GEN" --arg lb "$LB" \
'{generated: $gen, count: 1, traffic: $traffic, reader_signals: null,
ledger_bucket: (if $lb == "" then null else $lb end), articles: [$art]}')
echo "dispatching glow-up worker for $P"
gh workflow run content-review-article.yml \
--repo "${{ github.repository }}" \
-f path="$P" \
-f lane="glowup" \
-f mode="glowup" \
-f queue_json="$Q" \
|| echo "::warning::glow-up worker dispatch failed for $P"
done
# ---- Report-only lane (fact-check the trees nobody may edit) ----
# Same selector, same scoring, the OTHER half of the corpus: pages a
# generator owns (`editable: false, reviewable: true` in
# strategic-tiers.yaml — today the 248-page CLI command reference). The
# worker runs in mode=report: it extracts and verifies the page's claims,
# writes them to the claims index, and changes nothing. No model turn, no
# branch, no PR. Contradictions are reported to #docs-ops with a
# prefilled upstream issue; they never become stale-claims markers,
# because no PR in this repo could ever retire one.
#
# Why this lane exists: tier 0 meant "never select", and selection is the
# only thing that ever writes a page's claim list — so 254 pages, 30% of
# content/docs/, had never been fact-checked once. Not "checked and
# fine". pulumi/docs#20996.
#
# REPORT_REVIEW_COUNT repo variable — DEFAULT-OFF, deliberately inverting
# the default-on convention the other lanes use:
# unset -> OFF (no runner, no spend)
# 'N' -> on, N pages/working day
# The first pass over 248 never-checked pages is a one-time cost nobody
# has measured yet, and #20996 asks for it to be sized before the lane is
# committed to. Each run reports its own per-page token usage (the `cost`
# block in the report-findings artifact), so turning this on at a small N
# for a week produces the real number, and the lap time follows from it:
# at N pages/working day the tree laps every ceil(248/N) working days.
- name: Select and dispatch report-only reviews
if: >-
steps.select.outcome == 'success' &&
github.event.inputs.dry_run != 'true' &&
format('{0}', vars.REPORT_REVIEW_COUNT) != '' &&
format('{0}', vars.REPORT_REVIEW_COUNT) != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ARGS=(--mode report --count "${{ vars.REPORT_REVIEW_COUNT }}" --out .report-queue.json)
if [ -d .ledger-cache ]; then
ARGS+=(--ledger-dir .ledger-cache)
fi
if [ -f .traffic-snapshot ]; then
ARGS+=(--traffic-file .traffic-snapshot)
fi
if [ -f .reader-signals.json ]; then
ARGS+=(--signals-file .reader-signals.json)
fi
python3 scripts/content-review/select-articles.py "${ARGS[@]}"
echo "--- report-only queue ---"
cat .report-queue.json
# Same single-article queue_json shape as the two dispatch blocks
# above — keep all three jq fragments in sync, ledger_bucket included
# (the worker's publish job reads the bucket from the queue rather
# than from its own flaky stack-output resolve).
TRAFFIC=$(jq -c '.traffic' .report-queue.json)
SIGNALS=$(jq -c '.reader_signals // null' .report-queue.json)
GEN=$(jq -r '.generated' .report-queue.json)
LB="${{ steps.ledger-bucket.outputs.bucket }}"
jq -c '.articles[]' .report-queue.json | while read -r a; do
P=$(echo "$a" | jq -r '.path')
Q=$(jq -nc --argjson art "$a" --argjson traffic "$TRAFFIC" \
--argjson signals "$SIGNALS" --arg gen "$GEN" --arg lb "$LB" \
'{generated: $gen, count: 1, mode: "report", traffic: $traffic,
reader_signals: $signals,
ledger_bucket: (if $lb == "" then null else $lb end), articles: [$art]}')
echo "dispatching report-only worker for $P"
gh workflow run content-review-article.yml \
--repo "${{ github.repository }}" \
-f path="$P" \
-f lane="report" \
-f mode="report" \
-f queue_json="$Q" \
|| echo "::warning::report-only worker dispatch failed for $P"
done
# Breadcrumb for the ledger-write health signal: what was dispatched
# today. Next run's signal-health compares these slugs against the
# synced ledger cache — a dispatched page with no fresh ledger record
# means a worker's write path failed (the silent-duplicate bug).
# Schedule-only, like the health-state persist, so test dispatches
# don't pollute the observation.
- name: Record dispatch breadcrumb
if: >-
always() && github.event_name == 'schedule' &&
steps.select.outputs.has_articles == 'true' &&
steps.ledger-bucket.outputs.health_uri != ''
continue-on-error: true
run: |
# Order matters: pull the PREVIOUS run's breadcrumb down first (the
# signal-health step below judges it against the ledger cache synced
# at the top of this run), then overwrite it with today's dispatch.
aws s3 cp "${{ steps.ledger-bucket.outputs.health_uri }}last-dispatch.json" \
.last-dispatch.json --quiet \
|| echo "no prior dispatch breadcrumb (first run or S3 unavailable)"
jq -c '{date: (.generated | split("T")[0]), slugs: [.articles[].slug]}' \
.content-review-queue.json > .last-dispatch.next.json
aws s3 cp .last-dispatch.next.json \
"${{ steps.ledger-bucket.outputs.health_uri }}last-dispatch.json" --quiet
# Surface a halted selection (max_open_prs / gh_unavailable) in the run
# log — the select step already prints the queue JSON, this just makes the
# halt reason unmissable on a quiet run.
- name: Report halt
if: steps.select.outputs.halted != ''
env:
HALTED: ${{ steps.select.outputs.halted }}
run: |
echo "::warning::Existing-content selection halted: ${HALTED} (no articles dispatched)"
# --- Degradation-health lane (issue #20078 §3.4). Observability only:
# every step is fail-open (continue-on-error) and must never break the
# dispatcher. signal-health.py is a pure function of (state file,
# observations, today); these steps move the bytes: state is one JSON
# object in the ledger bucket under health/, and after THRESHOLD_DAYS of
# continuous degradation of any input (traffic snapshot, pulumi/pulumi-service
# access, holiday feed) a one-message alert posts to #docs-ops (the
# check-links.yml posting pattern), re-alerting weekly, not daily.
# workflow_dispatch runs are log-only: the state is computed and printed
# but never synced up, and Slack is never posted, so test dispatches
# don't pollute the day-counters. The script also emits a ::warning::
# per degraded signal on every run — the stateless fallback that still
# surfaces in the run summary when S3 itself is what's broken.
- name: Fetch health state from S3
if: always() && steps.ledger-bucket.outputs.health_uri != ''
continue-on-error: true
run: |
aws s3 cp "${{ steps.ledger-bucket.outputs.health_uri }}state.json" .health-state.json --quiet \
|| echo "no prior health state (first run or S3 unavailable)"
- name: Record degradation signals
if: always()
continue-on-error: true
run: |
python3 scripts/content-review/signal-health.py \
--state .health-state.json \
--queue .content-review-queue.json \
--console-status "${{ steps.console-probe.outputs.status }}" \
--holiday-status "${{ needs.holiday-check.outputs.feed_status }}" \
--last-dispatch .last-dispatch.json \
--ledger-dir .ledger-cache \
--alert-out .health-alert.txt \
--run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "--- health state ---"
cat .health-state.json
# Post before persisting: a job that dies between the two steps then
# re-alerts next run (a harmless duplicate) instead of never alerting.
# The residual gap — post fails but persist succeeds, muting the re-alert
# for one REALERT_DAYS cycle — is covered by the script's per-run
# ::warning:: fallback.
- name: Post degradation alert to Slack
if: always() && github.event_name == 'schedule' && hashFiles('.health-alert.txt') != ''
continue-on-error: true
env:
SLACK_ACCESS_TOKEN: ${{ steps.esc-secrets.outputs.SLACK_ACCESS_TOKEN }}
run: |
curl -s -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer ${SLACK_ACCESS_TOKEN}" \
-H "Content-type: application/json; charset=utf-8" \
--data "$(jq -n --arg ch "#docs-ops" --rawfile txt .health-alert.txt \
'{channel: $ch, text: $txt, unfurl_links: false}')"
- name: Persist health state to S3
if: always() && github.event_name == 'schedule' && steps.ledger-bucket.outputs.health_uri != ''
continue-on-error: true
run: |
aws s3 cp .health-state.json "${{ steps.ledger-bucket.outputs.health_uri }}state.json" --quiet
env:
ESC_ACTION_OIDC_AUTH: true
ESC_ACTION_OIDC_ORGANIZATION: pulumi
ESC_ACTION_OIDC_REQUESTED_TOKEN_TYPE: urn:pulumi:token-type:access_token:organization
ESC_ACTION_ENVIRONMENT: github-secrets/pulumi-docs
ESC_ACTION_EXPORT_ENVIRONMENT_VARIABLES: false