Skip to content

Scheduled jobs: Review existing content (dispatcher) #37

Scheduled jobs: Review existing content (dispatcher)

Scheduled jobs: Review existing content (dispatcher) #37

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 draft PR; the re-lint
# gate promotes it to ready (firing the normal triage -> review chain) and
# arms GitHub auto-merge. master requires an approving review + the build
# check, so a human approval is still required to merge — auto-merge only
# removes the manual merge click after approval.
#
# 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 job gate
# below is a plain string compare against '0' (the hard off); 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).
#
# 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' ||
(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.
fetch-depth: 0
# 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@v6
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
id: aws-creds
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"
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. Until that
# export ships, this resolve logs the skip and selection scores exactly
# as before — the consumer side here is inert-but-ready by design
# (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 [ -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"
# Deterministic selection: the model never chooses what to review.
# Writes has_articles= / halted= / count= 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)
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" \
'{generated: $gen, count: 1, traffic: $traffic, reader_signals: $signals, 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
# 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 }}" \
--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