run-pull #700
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: run-pull | |
| # Refresh tracked dockets — two forward writer jobs on one surface, at most one per run: | |
| # | |
| # - `pull` (the `17 *` crons, dispatch mode=pull, or a `run:pull` label): | |
| # targeted CourtListener *enrichment* for the tracked set — | |
| # not the primary freshness mechanism. CourtListener discovery is off (`pull. | |
| # discover_new_filings: false`): the live channel owns SCOTUS onboarding, and | |
| # circuit discovery onboarded cases outside the prediction scope. | |
| # - `live` (the `47 *` crons or dispatch mode=live): the SCOTUS live channel | |
| # (docs/live-sources.md) — `fedcourts live-poll` probes supremecourt.gov | |
| # docket JSON for newly docketed petitions (persisted per-Term cursor) and | |
| # re-polls the pending watchlist. No CourtListener token, no API budget; the | |
| # same corpus-writer plumbing (corpus push, pointer commit, queue handoffs). | |
| # | |
| # The historical Term walker (the backfill of past Terms) lives in its own | |
| # workflow, run-seed.yml, so its schedule is independent of these forward | |
| # windows; it shares this repo's `corpus-write` concurrency group, so it still | |
| # serializes with these forward writers. See run-seed.yml for that job. | |
| # | |
| # Each scheduled run fires exactly one of the two (the cron minute is the mode | |
| # switch), so the shared corpus-write lock serializes writers. The live and pull | |
| # windows interleave every ~3h; a run-seed window sits in a dead zone between | |
| # them and frees the lock before forward work needs it. Note GitHub keeps only | |
| # the latest *pending* run per concurrency group: a queued scheduled window | |
| # superseded by the next one just picks up where its rotation left off. | |
| # Spreading the refresh over several windows lifts daily throughput toward the | |
| # CourtListener tier's daily ceiling while each window stays under the hourly one. | |
| # Deterministic (no agent): re-fetches tracked dockets | |
| # and detects resolution of open events — | |
| # writing `outcome.json` when the disposition is machine-readable — ingesting the | |
| # raw facts (including each refresh's point-in-time snapshot) into the S3-hosted | |
| # corpus (pulled from / pushed back to the remote via `fedcourts corpus-pull` / | |
| # `corpus-push`). The new outcomes and the | |
| # small corpus pointer are committed DIRECTLY to main (they are CourtListener | |
| # facts, not agent output — see docs/data-pipeline.md). It then opens | |
| # a `run:predict` issue for each changed case with open events and a `run:evaluate` | |
| # issue for each case that gained an outcome; a case that appears decided but | |
| # whose outcome could not be recorded deterministically is surfaced on the | |
| # pipeline-runs dashboard for maintainer triage (no issue is filed for it). | |
| # Run logging creates nothing on the happy path: every window (pull and live, | |
| # success or failure) lands its row on the single long-lived `Pipeline runs` | |
| # dashboard issue, edited in place (.github/actions/run-log-dashboard), and a | |
| # failing window opens (or reuses, same day) a `pull-log` / `live-log` issue | |
| # left open for a human — so an open run-log issue means exactly "a window | |
| # broke". The corpus commits and the run:predict / run:evaluate handoff issues | |
| # use the GitHub App token so they trigger downstream workflows; the logging | |
| # writes (dashboard + run-log issues) ride the ambient GITHUB_TOKEN instead — | |
| # they must trigger nothing, and their labels are deliberately NOT `run:*` | |
| # trigger labels, so no logging issue re-fires pull. | |
| on: | |
| schedule: | |
| # Four pull windows/day, six hours apart (staggered off the hour from other | |
| # jobs). Spreading the work keeps each run under the hourly CourtListener | |
| # ceiling while the day's total approaches the daily one. The minute is the | |
| # job selector: `17 *` crons run `pull` and `47 *` run `live`. (The | |
| # historical walker's `31` windows live in run-seed.yml.) | |
| - cron: "17 1,7,13,19 * * *" | |
| # Four live windows/day, interleaved between the pull windows so the | |
| # corpus writers rarely even queue on the shared lock. Budget-free | |
| # (supremecourt.gov), so cadence is a freshness/politeness choice — the | |
| # conference watchlist will retune it around the cert calendar. | |
| - cron: "47 4,10,16,22 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| mode: | |
| description: "Which refresh to run (the historical walker is dispatched via run-seed.yml)" | |
| type: choice | |
| options: [pull, live] | |
| default: pull | |
| issues: | |
| types: [labeled] | |
| permissions: {} | |
| # All three jobs write the single corpus.db pointer, so they run under ONE lock | |
| # to serialize corpus writers and prevent lost updates. | |
| # cancel-in-progress is false so a queued run waits rather than being dropped. | |
| # Only a run that will actually write the corpus joins the shared group: every | |
| # `issues: labeled` event spawns a run of this workflow (the label filter is the | |
| # job-level `if`, evaluated after concurrency), so a run that the `if` will skip | |
| # is given a unique throwaway group — otherwise it could cancel an in-flight | |
| # writer as the group's pending slot before ever being skipped. | |
| concurrency: | |
| group: ${{ (github.event_name != 'issues' || github.event.label.name == 'run:pull') && 'corpus-write' || format('corpus-write-skip-{0}', github.run_id) }} | |
| cancel-in-progress: false | |
| jobs: | |
| pull: | |
| # One writer job per run: the cron minute (17 = pull, 47 = live) or the | |
| # dispatch `mode` input selects the job; a `run:pull` label always means pull. | |
| if: >- | |
| (github.event_name == 'schedule' && startsWith(github.event.schedule, '17 ')) || | |
| (github.event_name == 'workflow_dispatch' && inputs.mode == 'pull') || | |
| (github.event_name == 'issues' && github.event.label.name == 'run:pull') | |
| runs-on: ubuntu-latest | |
| # A single window's refresh (≤ max_cases_per_run dockets ≈ a few dozen | |
| # CourtListener fetches + S3 corpus transfer) finishes well inside this; the cap | |
| # releases the shared corpus-write lock far sooner than GitHub's 6h default if | |
| # a network step hangs. | |
| timeout-minutes: 45 | |
| environment: prod | |
| # Corpus-split (phase 1) dual-write flag, resolved from the `prod` environment | |
| # (falling back to a repo/org variable of the same name). Set CASESTORE_URL to | |
| # s3://<bucket>[/<prefix>] to mirror each mutated case to the per-case content | |
| # store (fedcourtsai.casestore) alongside the corpus blob; unset → "" → off (the | |
| # default). Best-effort — a mirror failure only logs, never fails ingestion; | |
| # enabling also needs the read-write S3 role to permit PutObject + | |
| # GetObject/HeadObject (the write-once existence check) on that prefix. | |
| # FEDCOURTS_CORPUS_SPLIT is the phase-4 go-live switch (flipped at the | |
| # clean-slate cutover): '1' stops packing payloads into the blob (the corpus | |
| # collapses to the small metadata index) and routes the writer's own payload | |
| # reads to the store. Unset variable → '0' → off; needs CASESTORE_URL set. | |
| # PREDICT_HANDOFF_ENABLED / EVALUATE_HANDOFF_ENABLED pause their fan-out's | |
| # trigger issues without pausing ingestion — set either to '0' in the | |
| # `prod` environment (a repo-level variable of the same name works too) | |
| # and that channel keeps collecting and committing while it stays quiet (a | |
| # provider spend cap, a pipeline change being staged). Unset → '1' → | |
| # filing, so a mistyped variable keeps the tournament running rather than | |
| # silently stopping it. | |
| # | |
| # Both queues are level-triggered, so holding either costs latency alone, | |
| # never a missed prediction/grading: predict's selection sweep re-derives | |
| # its queue from corpus state, and evaluate's backlog deriver (see | |
| # docs/pipeline.md) re-derives owed gradings from committed ledger state on | |
| # a later cycle. See the handoff action. | |
| env: | |
| FEDCOURTS_CASESTORE_URL: ${{ vars.CASESTORE_URL }} | |
| FEDCOURTS_CORPUS_SPLIT: ${{ vars.FEDCOURTS_CORPUS_SPLIT || '0' }} | |
| PREDICT_HANDOFF_ENABLED: ${{ vars.PREDICT_HANDOFF_ENABLED || '1' }} | |
| EVALUATE_HANDOFF_ENABLED: ${{ vars.EVALUATE_HANDOFF_ENABLED || '1' }} | |
| permissions: | |
| contents: write | |
| issues: write | |
| id-token: write # assume the AWS role for the corpus S3 remote (read-write) | |
| steps: | |
| # An issue *form* applies its declared labels on creation regardless of the | |
| # submitter's permissions, so on a public repo anyone could fire this | |
| # privileged run by filing the pull form. Require the triggering actor to | |
| # hold write access; deny everyone else. Fail closed if it can't be read. | |
| # (The label name is already checked in the job `if:`.) | |
| - name: Authorize the trigger (issue label must come from a maintainer) | |
| id: authorize | |
| if: github.event_name == 'issues' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| ACTOR: ${{ github.actor }} | |
| run: | | |
| set -euo pipefail | |
| perm=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \ | |
| --jq '.permission' 2>/dev/null || echo none) | |
| case "$perm" in | |
| admin | maintain | write) echo "Authorized ${ACTOR} (${perm} access)." ;; | |
| *) | |
| echo "::error::${ACTOR} lacks write access (permission: ${perm}); refusing to run." >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Mint app token | |
| id: app-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 | |
| with: | |
| client-id: ${{ vars.DATA_APP_CLIENT_ID }} | |
| private-key: ${{ secrets.DATA_APP_PRIVATE_KEY }} | |
| permission-contents: write | |
| permission-issues: write | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Persist the App token so refreshed snapshots can be pushed back to main; | |
| # this job uploads no artifacts, so credential leakage is not a concern. | |
| # The id gates the dashboard step below: the composite action it uses | |
| # lives in this tree, so it can only run once checkout has succeeded. | |
| id: checkout | |
| with: # zizmor: ignore[artipacked] | |
| token: ${{ steps.app-token.outputs.token }} | |
| fetch-depth: 0 | |
| - name: Sync the working tree to the current tip of the default branch | |
| # actions/checkout pins this run's frozen github.sha — the branch tip when | |
| # the run was CREATED, not when it starts. A run that queues behind another | |
| # corpus writer on the shared corpus-write lock therefore checks out a | |
| # stale base, so the corpus blob it pulls and mutates is built on an | |
| # obsolete pointer and the final commit cannot rebase onto the advanced tip | |
| # (an unmergeable corpus/corpus.db.ref conflict). Reset to the live tip | |
| # first, under the lock, so we always mutate the latest committed corpus — | |
| # the invariant docs/data-pipeline.md's corpus-writer coordination relies | |
| # on. The repo is public, so this read-only fetch needs no credentials. | |
| run: | | |
| set -euo pipefail | |
| git fetch origin "${GITHUB_REF_NAME}" | |
| git reset --hard FETCH_HEAD | |
| - uses: ./.github/actions/setup-python-env | |
| # Assume the read-write (append-only) IAM role via GitHub OIDC so pull can | |
| # push fresh forward facts into the corpus on the S3 remote. The role | |
| # grants get/put/list but no delete, so a run can add corpus objects but | |
| # never wipe them. No static keys; the role ARN and region come from the | |
| # `prod` environment. | |
| - name: Configure AWS credentials (corpus S3 remote, read-write) | |
| uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 | |
| with: | |
| role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME }} | |
| aws-region: ${{ vars.AWS_REGION }} | |
| # Bound the assume-role wall clock: unbounded, its 12 retries over a | |
| # multi-minute connect timeout outlast every job budget here (see the | |
| # workflow-traps list in docs/pipeline.md). Note the cost this buys — | |
| # the action does not clear its timer on the error path, so ANY failing | |
| # assume-role now burns the full 120s and ends on "Action timed out" | |
| # rather than its own message; the real cause is earlier in the log. | |
| action-timeout-s: 120 | |
| - name: Pull the current corpus from the remote | |
| env: | |
| # The remote's bucket URL, out of band (never committed; see | |
| # SECURITY.md); boto3 picks up the OIDC-assumed credentials from the | |
| # environment. Sourced from the CORPUS_REMOTE_URL repo variable | |
| # (the CLI also still accepts the legacy DVC_REMOTE_URL env alias). | |
| CORPUS_REMOTE_URL: ${{ vars.CORPUS_REMOTE_URL }} | |
| run: | | |
| # Discovery and the rotation governor read prior corpus state (the | |
| # discovery watermark and per-case last_pulled), so it must be | |
| # restored. A first run has no committed pointer yet — warn and start | |
| # a fresh corpus. The pull is checksum-verified against the pointer. | |
| uv run fedcourts corpus-pull --missing-pointer warn | |
| - name: Refresh tracked dockets | |
| env: | |
| FEDCOURTS_COURTLISTENER_API_TOKEN: ${{ secrets.COURTLISTENER_API_TOKEN }} | |
| # CourtListener per-token rate limits the client throttles to | |
| # (config.py courtlistener_rpm/rph/rpd). The repo/environment variables | |
| # are the knob — set them to the active Free Law Project tier to retune | |
| # without a code change. The `|| '...'` fallbacks default to the pilot's | |
| # held tier (Tier 2 = 15/150/600), so an unset variable lands on the | |
| # correct ceiling rather than silently throttling to the free tier (an | |
| # unset Actions variable renders as an empty string, which would also | |
| # fail int parsing — the fallback avoids that crash). Drop these to the | |
| # free-tier 5/50/125 if the membership ever lapses. | |
| FEDCOURTS_COURTLISTENER_RPM: ${{ vars.COURTLISTENER_RPM || '15' }} | |
| FEDCOURTS_COURTLISTENER_RPH: ${{ vars.COURTLISTENER_RPH || '150' }} | |
| FEDCOURTS_COURTLISTENER_RPD: ${{ vars.COURTLISTENER_RPD || '600' }} | |
| run: | | |
| uv run fedcourts pull-all \ | |
| --out predict-queue.json \ | |
| --evaluate-out evaluate-queue.json \ | |
| --unrecorded-out unrecorded-queue.json \ | |
| --limit 50 | |
| - name: Push the corpus blob to the remote | |
| env: | |
| CORPUS_REMOTE_URL: ${{ vars.CORPUS_REMOTE_URL }} | |
| run: | | |
| # pull-all wrote fresh facts, discovered filings, and bumped tracking | |
| # state in corpus/corpus.db. corpus-push uploads the blob to its | |
| # content-addressed key (add-only remote) and rewrites the pointer — | |
| # blob before pointer commit, so the committed pointer always | |
| # resolves against the remote. | |
| uv run fedcourts corpus-push | |
| - name: Configure git identity | |
| uses: ./.github/actions/configure-git-identity | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| app-slug: ${{ steps.app-token.outputs.app-slug }} | |
| # Facts go straight to main: the corpus pointer + any detected outcomes. The | |
| # rebase-and-backoff retry (which outlasts a transient GitHub ref blip and | |
| # names the real failure cause) lives in the shared composite action so all | |
| # three writer jobs push identically. | |
| - name: Commit outcomes and corpus pointer to main | |
| id: commit | |
| uses: ./.github/actions/commit-corpus-to-main | |
| with: | |
| message: "pull: refresh corpus + outcomes" | |
| label: outcomes + corpus pointer | |
| append-date: 'true' | |
| # Trailing, NON-BLOCKING data-validation: the corpus is already pulled here, so | |
| # produce the correctness verdict (corpus integrity + ledger referential checks) | |
| # and publish it to the ops-metrics branch for run-ops to present and escalate. | |
| # continue-on-error keeps a failing verdict (or a publish hiccup) from failing | |
| # the refresh — the verdict is loud-not-fatal by contract. | |
| - name: Produce + publish the corpus validation verdict | |
| continue-on-error: true | |
| uses: ./.github/actions/publish-corpus-verdict | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| # One run:predict issue for the whole window's queue, not one per case: | |
| # predict-matrix parses a JSON array of cases, so the handoff is a single | |
| # issue + workflow run that fans out one job per case x predictor (throttled | |
| # by the matrix max-parallel) instead of N issues each spawning their own run. | |
| - name: Open the run:predict handoff for changed cases | |
| id: predict-handoff | |
| uses: ./.github/actions/open-run-handoff | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| repo: ${{ github.repository }} | |
| queue: predict-queue.json | |
| label: run:predict | |
| noun: predict | |
| enabled: ${{ env.PREDICT_HANDOFF_ENABLED }} | |
| # Likewise one run:evaluate issue for the window's newly-resolved cases. | |
| - name: Open the run:evaluate handoff for newly resolved events | |
| id: evaluate-handoff | |
| uses: ./.github/actions/open-run-handoff | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| repo: ${{ github.repository }} | |
| queue: evaluate-queue.json | |
| label: run:evaluate | |
| noun: evaluate | |
| enabled: ${{ env.EVALUATE_HANDOFF_ENABLED }} | |
| # Success or failure, the window lands its row on the long-lived | |
| # `Pipeline runs` dashboard issue (edited in place; the unrecorded | |
| # outcomes ride along per case for maintainer triage). Never on | |
| # cancellation — a queued window superseded on the shared lock is not an | |
| # outcome. A failure before checkout cannot land a row (the composite | |
| # lives in this tree) and is covered by the failure issue below. | |
| # The ambient GITHUB_TOKEN (this job holds `issues: write`), not the App | |
| # token: the dashboard must trigger nothing, so it stays on the | |
| # lower-trust, non-bypass credential — the same doctrine as run-ops and | |
| # the collect/plan issue writes (docs/security.md). | |
| # Loud-not-fatal like publish-corpus-verdict: a transient dashboard | |
| # hiccup must not mark a healthy window failed (and so file a false | |
| # alarm); a stale dashboard is self-evident from its newest row's | |
| # timestamp. | |
| - name: Update the pipeline-runs dashboard | |
| continue-on-error: true | |
| if: >- | |
| (success() || failure()) && | |
| steps.checkout.conclusion == 'success' | |
| uses: ./.github/actions/run-log-dashboard | |
| with: | |
| token: ${{ github.token }} | |
| repo: ${{ github.repository }} | |
| channel: pull | |
| outcome: ${{ job.status }} | |
| committed: ${{ steps.commit.outputs.committed }} | |
| predict-note: ${{ steps.predict-handoff.outputs.note }} | |
| evaluate-note: ${{ steps.evaluate-handoff.outputs.note }} | |
| # Ops analytics: the same counts in the Actions UI, no roll-up needed. | |
| - name: Roll the window up on the Actions summary | |
| if: success() | |
| env: | |
| COMMITTED: ${{ steps.commit.outputs.committed }} | |
| PREDICT_NOTE: ${{ steps.predict-handoff.outputs.note }} | |
| EVALUATE_NOTE: ${{ steps.evaluate-handoff.outputs.note }} | |
| run: | | |
| set -euo pipefail | |
| predict=$(jq 'length' predict-queue.json) | |
| evaluate=$(jq 'length' evaluate-queue.json) | |
| unrecorded=$(jq 'length' unrecorded-queue.json) | |
| if [ "${COMMITTED:-false}" = "true" ]; then | |
| snap="Committed the refreshed corpus pointer + new outcomes to the default branch." | |
| else | |
| snap="No outcome or corpus changes this window." | |
| fi | |
| { | |
| echo "## run-pull — $(date -u +%Y-%m-%d)" | |
| echo "$snap" | |
| echo "" | |
| echo "| Handoff | Count |" | |
| echo "|---------|------:|" | |
| echo "| run:predict cases queued | ${predict}${PREDICT_NOTE} |" | |
| echo "| run:evaluate cases queued | ${evaluate}${EVALUATE_NOTE} |" | |
| echo "| outcomes left unrecorded | ${unrecorded} |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Failure-only alarm: an open `pull-log` issue means exactly "a pull | |
| # window broke" — nothing is filed on the happy path, and nothing closes | |
| # this automatically; a human closes it once triaged. Inline (no | |
| # checkout needed) and on the ambient GITHUB_TOKEN (non-triggering write, | |
| # same doctrine as the dashboard step above), so it fires for any broken | |
| # window — including one whose App-token mint itself failed. The one | |
| # suppression is a failed authorize step: an unauthorized labeling | |
| # attempt must stay fail-closed and file nothing (the step is skipped, | |
| # not failed, on schedule/dispatch runs, so those alarm normally). | |
| # `cancelled()` is included because the shared lock never cancels an | |
| # in-flight run (cancel-in-progress is false; a superseded run dies | |
| # queued, before any step) — so an in-flight cancellation is the job | |
| # timeout or a human's stop, and both are windows that did not complete. | |
| - name: Open the failure run-log issue | |
| if: (failure() || cancelled()) && steps.authorize.conclusion != 'failure' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| WINDOW: ${{ github.event_name == 'schedule' && github.event.schedule || github.event_name }} | |
| STATUS: ${{ job.status }} | |
| run: | | |
| set -euo pipefail | |
| # `pull-log` is a NON-triggering label by design: it is not a `run:*` | |
| # label, so labeling this issue cannot re-fire the workflow | |
| # (issues: labeled) in a loop — unlike run:pull, which is the trigger. | |
| # Create it idempotently so a fresh repo's first failure does not | |
| # fail on a missing label. | |
| gh label create pull-log --repo "$REPO" --force \ | |
| --color b60205 --description "A run-pull refresh window failed (open until a human triages)" | |
| today=$(date -u +%Y-%m-%d) | |
| # One issue per broken day: a day's repeated failures collapse into | |
| # one thread, while an older day's still-open issue is its own | |
| # record and is left alone. | |
| num=$(gh issue list --repo "$REPO" --label pull-log --state open \ | |
| --limit 100 --json number,title | jq -r --arg d "$today" \ | |
| '[.[] | select(.title | endswith($d))][0].number // empty') | |
| body=$(printf '### ❌ Pull refresh window did not complete\n\n- Run: %s\n- Window: `%s` at %s UTC — status: `%s`\n\nSee the run log for the failing step; the `Pipeline runs` dashboard carries the window history. Close this issue once triaged — an open `pull-log` issue means exactly "a pull window broke".\n' \ | |
| "$RUN_URL" "$WINDOW" "$(date -u +%H:%M)" "$STATUS") | |
| if [ -z "$num" ]; then | |
| url=$(gh issue create --repo "$REPO" \ | |
| --title "pull: window failure — ${today}" \ | |
| --label pull-log --body "$body") | |
| echo "Opened pull-log failure issue: ${url}" | |
| else | |
| gh issue comment "$num" --repo "$REPO" --body "$body" | |
| echo "Reusing today's open pull-log issue #${num}" | |
| fi | |
| live: | |
| # The SCOTUS live channel (docs/live-sources.md): `fedcourts live-poll` | |
| # probes supremecourt.gov docket JSON for newly docketed petitions and | |
| # re-polls the pending watchlist — no CourtListener token, no API budget. | |
| # Same corpus-writer shape and privileges as `pull` (App token + the | |
| # read-write corpus role), but schedule/dispatch only: there is no `run:live` | |
| # label, so nothing an outside actor can file fires this job and no | |
| # issue-form authorization gate is needed. | |
| if: >- | |
| (github.event_name == 'schedule' && startsWith(github.event.schedule, '47 ')) || | |
| (github.event_name == 'workflow_dispatch' && inputs.mode == 'live') | |
| runs-on: ubuntu-latest | |
| # The cycle re-polls the pending watchlist and onboards the frontier at ~1 | |
| # req/s, plus document fetches on distribution transitions (which cluster at | |
| # the September long conference) and the S3 transfer. `live-poll` self-bounds | |
| # to a soft wall-clock budget (`--max-run-seconds` below), committing progress | |
| # so far and resuming next cycle, so a big watchlist can never overrun this | |
| # hard cap — which stays above the soft budget with margin for the fixed | |
| # setup (checkout + corpus-pull) before the poll and the push after it. | |
| timeout-minutes: 35 | |
| environment: prod | |
| # Corpus-split flags (see the pull job): dual-write off unless CASESTORE_URL | |
| # is set; FEDCOURTS_CORPUS_SPLIT is the phase-4 go-live switch (unset → '0' → off). | |
| # PREDICT_HANDOFF_ENABLED / EVALUATE_HANDOFF_ENABLED likewise mirror `pull` | |
| # — both writers queue at the same seam, so a pause has to hold both or | |
| # trigger issues keep arriving from whichever channel is left unheld. | |
| env: | |
| FEDCOURTS_CASESTORE_URL: ${{ vars.CASESTORE_URL }} | |
| FEDCOURTS_CORPUS_SPLIT: ${{ vars.FEDCOURTS_CORPUS_SPLIT || '0' }} | |
| PREDICT_HANDOFF_ENABLED: ${{ vars.PREDICT_HANDOFF_ENABLED || '1' }} | |
| EVALUATE_HANDOFF_ENABLED: ${{ vars.EVALUATE_HANDOFF_ENABLED || '1' }} | |
| # The ambient GITHUB_TOKEN carries exactly the non-triggering issue writes | |
| # (the pipeline-runs dashboard row and the failure run-log issue — the | |
| # doctrine in docs/security.md: issue writes that must trigger nothing stay | |
| # off the App token). Checkout, the corpus push, and the handoff issues run | |
| # on the App token, and there is no authorize-trigger step to need a read. | |
| permissions: | |
| contents: read | |
| issues: write # the dashboard + failure-issue writes (non-triggering, ambient) | |
| id-token: write # assume the AWS role for the corpus S3 remote (read-write) | |
| steps: | |
| - name: Mint app token | |
| id: app-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 | |
| with: | |
| client-id: ${{ vars.DATA_APP_CLIENT_ID }} | |
| private-key: ${{ secrets.DATA_APP_PRIVATE_KEY }} | |
| permission-contents: write | |
| permission-issues: write | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Persist the App token so outcomes + the corpus pointer can be pushed | |
| # back to main; this job uploads no artifacts, so credential leakage is | |
| # not a concern. The id gates the dashboard step below: its composite | |
| # action lives in this tree, so it needs a successful checkout first. | |
| id: checkout | |
| with: # zizmor: ignore[artipacked] | |
| token: ${{ steps.app-token.outputs.token }} | |
| fetch-depth: 0 | |
| - name: Sync the working tree to the current tip of the default branch | |
| # actions/checkout pins this run's frozen github.sha — the branch tip when | |
| # the run was CREATED, not when it starts. A run that queues behind another | |
| # corpus writer on the shared corpus-write lock therefore checks out a | |
| # stale base, so the corpus blob it pulls and mutates is built on an | |
| # obsolete pointer and the final commit cannot rebase onto the advanced tip | |
| # (an unmergeable corpus/corpus.db.ref conflict). Reset to the live tip | |
| # first, under the lock, so we always mutate the latest committed corpus — | |
| # the invariant docs/data-pipeline.md's corpus-writer coordination relies | |
| # on. The repo is public, so this read-only fetch needs no credentials. | |
| run: | | |
| set -euo pipefail | |
| git fetch origin "${GITHUB_REF_NAME}" | |
| git reset --hard FETCH_HEAD | |
| - uses: ./.github/actions/setup-python-env | |
| # Same read-write (append-only) role as `pull`: the live poller adds | |
| # snapshots and rows to the corpus blob but can never wipe remote objects. | |
| - name: Configure AWS credentials (corpus S3 remote, read-write) | |
| uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 | |
| with: | |
| role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME }} | |
| aws-region: ${{ vars.AWS_REGION }} | |
| # Bound the assume-role wall clock: unbounded, its 12 retries over a | |
| # multi-minute connect timeout outlast every job budget here (see the | |
| # workflow-traps list in docs/pipeline.md). Note the cost this buys — | |
| # the action does not clear its timer on the error path, so ANY failing | |
| # assume-role now burns the full 120s and ends on "Action timed out" | |
| # rather than its own message; the real cause is earlier in the log. | |
| action-timeout-s: 120 | |
| - name: Pull the current corpus from the remote | |
| env: | |
| # See the pull job: CORPUS_REMOTE_URL variable, same env name. | |
| CORPUS_REMOTE_URL: ${{ vars.CORPUS_REMOTE_URL }} | |
| run: | | |
| # The live rotation and the per-Term discovery cursors read prior | |
| # corpus state, so it must be restored; a first run has no committed | |
| # pointer yet — warn and start fresh. Checksum-verified. | |
| uv run fedcourts corpus-pull --missing-pointer warn | |
| - name: Poll the live channel | |
| # No token, no budget env: politeness (browser UA, ~1 req/s) is built | |
| # into the client; caps come from tracking.yaml's `live:` section. | |
| # --max-run-seconds is the soft wall-clock budget: the cycle stops | |
| # cleanly and commits progress so far when reached, so even a peak | |
| # watchlist stays under the job's 35 min timeout-minutes with room for | |
| # the fixed setup and the push (1500s = 25 min; the next cycle resumes | |
| # the rotation where it left off). | |
| run: | | |
| uv run fedcourts live-poll \ | |
| --max-run-seconds 1500 \ | |
| --out predict-queue.json \ | |
| --evaluate-out evaluate-queue.json \ | |
| --unrecorded-out unrecorded-queue.json | |
| - name: Push the corpus blob to the remote | |
| env: | |
| CORPUS_REMOTE_URL: ${{ vars.CORPUS_REMOTE_URL }} | |
| run: | | |
| # live-poll wrote fresh snapshots, rows, and cursors into | |
| # corpus/corpus.db. Blob before pointer commit (content-addressed, | |
| # add-only), so the committed pointer always resolves against the | |
| # remote. | |
| uv run fedcourts corpus-push | |
| - name: Configure git identity | |
| uses: ./.github/actions/configure-git-identity | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| app-slug: ${{ steps.app-token.outputs.app-slug }} | |
| # Same shared commit-and-push as `pull` (the retry lives in the action). | |
| - name: Commit outcomes and corpus pointer to main | |
| id: commit | |
| uses: ./.github/actions/commit-corpus-to-main | |
| with: | |
| message: "live: refresh corpus + outcomes" | |
| label: outcomes + corpus pointer | |
| append-date: 'true' | |
| - name: Open the run:predict handoff for changed cases | |
| id: predict-handoff | |
| uses: ./.github/actions/open-run-handoff | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| repo: ${{ github.repository }} | |
| queue: predict-queue.json | |
| label: run:predict | |
| noun: predict | |
| enabled: ${{ env.PREDICT_HANDOFF_ENABLED }} | |
| - name: Open the run:evaluate handoff for newly resolved events | |
| id: evaluate-handoff | |
| uses: ./.github/actions/open-run-handoff | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| repo: ${{ github.repository }} | |
| queue: evaluate-queue.json | |
| label: run:evaluate | |
| noun: evaluate | |
| enabled: ${{ env.EVALUATE_HANDOFF_ENABLED }} | |
| # Unrecorded outcomes are surfaced on the dashboard/summary below, exactly | |
| # as in `pull`: live resolution is machine-readable by construction (the | |
| # disposition orders ride in the proceedings text), so a non-empty | |
| # unrecorded queue signals a case shape worth manual triage. | |
| # Same dashboard row as `pull` (see that job's step for the gating, | |
| # token, and loud-not-fatal rationale) — one shared `Pipeline runs` | |
| # issue carries both channels. | |
| - name: Update the pipeline-runs dashboard | |
| continue-on-error: true | |
| if: >- | |
| (success() || failure()) && | |
| steps.checkout.conclusion == 'success' | |
| uses: ./.github/actions/run-log-dashboard | |
| with: | |
| token: ${{ github.token }} | |
| repo: ${{ github.repository }} | |
| channel: live | |
| outcome: ${{ job.status }} | |
| committed: ${{ steps.commit.outputs.committed }} | |
| predict-note: ${{ steps.predict-handoff.outputs.note }} | |
| evaluate-note: ${{ steps.evaluate-handoff.outputs.note }} | |
| - name: Roll the window up on the Actions summary | |
| if: success() | |
| env: | |
| COMMITTED: ${{ steps.commit.outputs.committed }} | |
| PREDICT_NOTE: ${{ steps.predict-handoff.outputs.note }} | |
| EVALUATE_NOTE: ${{ steps.evaluate-handoff.outputs.note }} | |
| run: | | |
| set -euo pipefail | |
| predict=$(jq 'length' predict-queue.json) | |
| evaluate=$(jq 'length' evaluate-queue.json) | |
| unrecorded=$(jq 'length' unrecorded-queue.json) | |
| if [ "${COMMITTED:-false}" = "true" ]; then | |
| snap="Committed the refreshed corpus pointer + new outcomes to the default branch." | |
| else | |
| snap="No outcome or corpus changes this window." | |
| fi | |
| { | |
| echo "## run-pull (live) — $(date -u +%Y-%m-%d)" | |
| echo "$snap" | |
| echo "" | |
| echo "| Handoff | Count |" | |
| echo "|---------|------:|" | |
| echo "| run:predict cases queued | ${predict}${PREDICT_NOTE} |" | |
| echo "| run:evaluate cases queued | ${evaluate}${EVALUATE_NOTE} |" | |
| echo "| outcomes left unrecorded | ${unrecorded} |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Failure-only alarm, mirroring `pull` (see that job's step for the | |
| # `cancelled()` and ambient-token rationale; there is no authorize step | |
| # here to gate on): an open `live-log` issue means exactly "a live | |
| # window broke"; a human closes it once triaged. | |
| - name: Open the failure run-log issue | |
| if: failure() || cancelled() | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| WINDOW: ${{ github.event_name == 'schedule' && github.event.schedule || github.event_name }} | |
| STATUS: ${{ job.status }} | |
| run: | | |
| set -euo pipefail | |
| # `live-log` mirrors `pull-log`: a NON-triggering label, created | |
| # idempotently, filed only when a window fails. | |
| gh label create live-log --repo "$REPO" --force \ | |
| --color b60205 --description "A run-pull live-channel window failed (open until a human triages)" | |
| today=$(date -u +%Y-%m-%d) | |
| # One issue per broken day: a day's repeated failures collapse into | |
| # one thread; an older day's still-open issue is left alone. | |
| num=$(gh issue list --repo "$REPO" --label live-log --state open \ | |
| --limit 100 --json number,title | jq -r --arg d "$today" \ | |
| '[.[] | select(.title | endswith($d))][0].number // empty') | |
| body=$(printf '### ❌ Live poll window did not complete\n\n- Run: %s\n- Window: `%s` at %s UTC — status: `%s`\n\nSee the run log for the failing step; the `Pipeline runs` dashboard carries the window history. Close this issue once triaged — an open `live-log` issue means exactly "a live window broke".\n' \ | |
| "$RUN_URL" "$WINDOW" "$(date -u +%H:%M)" "$STATUS") | |
| if [ -z "$num" ]; then | |
| url=$(gh issue create --repo "$REPO" \ | |
| --title "live: window failure — ${today}" \ | |
| --label live-log --body "$body") | |
| echo "Opened live-log failure issue: ${url}" | |
| else | |
| gh issue comment "$num" --repo "$REPO" --body "$body" | |
| echo "Reusing today's open live-log issue #${num}" | |
| fi |