# The publisher: reads the test-record store, folds what is new into a # rolling aggregate, scores every identity, and creates one manifest object. # Whatever reads a manifest reads the newest one. Nothing gates on this # workflow: when it fails, the manifest before it is still the newest, so # selection quality decays slowly rather than anything turning red. # # Its Google Cloud identity comes from a Workload Identity Federation # provider pinned to exactly this workflow file on main, so no other # workflow, ref, or repository can mint it (infra: tofu/test-records). The # account's identity-specific writer grant is objectCreator on the selection # prefix, so it cannot overwrite or delete. The dataset's public objectViewer # grant separately lets it read and list objects like any public reader. # # Four-hourly rather than daily because aggregation is incremental and so # cheap, and a flake that appears in the morning should not wait until the # small hours to be prioritized. Manual dispatch is there so somebody who # has just fixed something can refresh without waiting, and it carries the # bootstrap that a cold start needs. name: Test Selection on: schedule: - cron: "17 */4 * * *" workflow_dispatch: inputs: # The label says what this is for rather than what it reads. A run # with it set starts from an empty aggregate, and refuses where the # store holds an aggregate any publisher can read, naming it. bootstrap: description: >- COLD START. Starts the score history from nothing over the whole window. Refuses where any stored aggregate can be read. type: boolean default: false days: description: >- Days of submissions to read (default 2, or back to the last day the publisher read; 60 with a cold start) required: false permissions: contents: read id-token: write # Two runs folding from one aggregate would each create a state object, # and the one that finished later would become the newest while holding # the older snapshot. There is one publisher, so it runs one at a time, # and a queued run is never cancelled: skipping a cycle loses the records # that arrived during it. concurrency: group: test-selection-publisher cancel-in-progress: false jobs: publish: name: "Publish Test Selection Manifest" # The provider variable gates the whole job. Where test records are not # provisioned there is no identity to assume and nothing to create. if: vars.TEST_SELECTION_WIF_PROVIDER != '' runs-on: ubuntu-latest # Six hours is the ceiling for a job on a GitHub-hosted runner: past # it the platform cancels the run whatever this says. A bootstrap # reads sixty days of records in one job, and the four-hourly path # reads two, or back to the last day it read. The longest read is a # fold of the whole history after the rules change, which took 75 # minutes over the two months from 2026-08-19. timeout-minutes: 360 # Both steps that run the publisher name the store, so it is named # once for the job and the two cannot disagree about where it is. env: TEST_RECORDS_BUCKET: ${{ vars.TEST_RECORDS_BUCKET }} TEST_RECORDS_PREFIX: ${{ vars.TEST_RECORDS_PREFIX }} TEST_SELECTION_PREFIX: ${{ vars.TEST_SELECTION_PREFIX }} steps: # Every third-party action in this credentialed workflow is pinned to # an immutable commit, so a moved tag cannot alter the one privileged # write path. - name: 📥 Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # No cache, as the relay does for the same reason: the cache steps # inside this composite action are the one place a credentialed job # here still runs a third-party action by a mutable tag. This job # runs every four hours and is on nobody's critical path. - name: 🦕 Setup Deno uses: ./.github/actions/deno-setup with: cache: false # Resolve only what this job executes. A workspace-wide install makes # this independent publisher fail when an unrelated dependency cannot # be fetched. - name: 📦 Cache publisher dependencies run: >- deno cache --frozen tasks/test-selection-publish.ts tasks/step-summary.ts # The store is public to read, so building the manifest needs no # credential, and this step is not given the access token. The # token is minted after it, because a token lasts an hour and a run # that folds the whole history again takes longer than that. # # The dispatch inputs reach the shell through the environment rather # than through expansion into the script. An expansion is substituted # before the shell parses the line, so a crafted value would run as # part of this step, and every step of this job can ask for the # federated identity's token. # The window is quoted, then checked against the one shape it may # take before it becomes an argument. - name: 📊 Build the manifest env: PUBLISH_BOOTSTRAP: ${{ inputs.bootstrap }} PUBLISH_DAYS: ${{ inputs.days }} run: | set -euo pipefail args=() if [ "${PUBLISH_BOOTSTRAP:-}" = "true" ]; then args+=(--bootstrap) fi if [ -n "${PUBLISH_DAYS:-}" ]; then case "$PUBLISH_DAYS" in *[!0-9]*|"") echo "::error::days must be a positive whole number" exit 1 ;; esac if [ "$((PUBLISH_DAYS))" -lt 1 ]; then # A window of no days reads as a typo rather than a request, # and the failure belongs here, where the input was given, # rather than several steps later inside the publisher. echo "::error::days must be at least 1" exit 1 fi args+=(--days "$PUBLISH_DAYS") fi # The standard error joins the standard output in the log. The # publisher says on it what stopped a run — which stored # aggregates it could not read, what stopped it in each, and # what to do about it — and the summary below, which is where a # person reads a failed run, is built from this log alone. deno run --allow-read --allow-env --allow-net --allow-write \ tasks/test-selection-publish.ts --dry-run --out selection \ "${args[@]}" 2>&1 | tee publish.log - name: 🔑 Authenticate to Google Cloud id: auth uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 with: project_id: commontools-core workload_identity_provider: ${{ vars.TEST_SELECTION_WIF_PROVIDER }} service_account: ${{ vars.TEST_SELECTION_SERVICE_ACCOUNT }} token_format: access_token create_credentials_file: false - name: 📤 Create the manifest env: TEST_RECORDS_GCS_TOKEN: ${{ steps.auth.outputs.access_token }} run: | set -euo pipefail deno run --allow-read --allow-env --allow-net \ tasks/test-selection-publish.ts --create selection \ 2>&1 | tee -a publish.log # A job summary is taken whole or not at all: GitHub refuses one # past 1024 KiB, and what it held is lost rather than shortened. So # the log goes through the same cut every task in the tree writes a # summary through, and the rest stays in the job log, which holds # the run's output in full either way. The room asked for leaves # the heading and the fences out of it. - name: 📝 Summarize if: always() env: SUMMARY_BYTES: 1000000 run: | set -euo pipefail # Built whole before any of it is appended, so a step that dies # partway adds nothing rather than a heading and an opening # fence with no text and no closing fence under them. { echo "## Test selection" echo "" if [ -s publish.log ]; then # A `grep` that matched nothing is a log of nothing but # folded lines; one that could not read the file is a # failure, and says so by failing. grep -v '^test selection: folded [0-9]* of ' publish.log \ > summary.log || [ $? -eq 1 ] echo '```' deno run tasks/step-summary.ts --room "$SUMMARY_BYTES" \ < summary.log echo '```' else # The publish step died before it printed anything, or # never ran. An empty block here would read as a run with # nothing to report, which is the one thing it is not. echo "The publisher printed nothing. The job log has why." fi } > body.md cat body.md >> "$GITHUB_STEP_SUMMARY"