Scheduled jobs: Review existing content (dispatcher) #39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |