Skip to content

run-pull

run-pull #700

Workflow file for this run

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