feat(evals): merge-train workflow side, inert behind the train policy block (GSTA-49 T1b)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-18 19:59:22 +00:00
1 parent a844821583
commit 8128af38ec
16 files changed
+2265 -87

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
name: Main backstop gate
# GSTA-49 T1b (docs/ship-policy.md#run-a-merge-train). Every push to main is
# covered by a full gate. Under merge trains a train's merges are covered by
# the gate the train already ran on the same trees, so this workflow gates
# main only when no green-train record lists main's tree modulo stamps (a
# merge outside the queue), after any train has finished merging. A red
# backstop writes the freeze record gstack-ship-queue reads. Inert (no paid
# work, nothing written) unless main's .gstack/ship-policy.json declares a
# `train` block; without one the per-PR gates cover main as before.
on:
push:
branches: [main]
# One backstop at a time; a newer push waits and the script hands a superseded
# push to it (only the newest pending run is kept).
concurrency:
group: evals-main-backstop
cancel-in-progress: false
permissions: {}
jobs:
backstop:
runs-on: ubuntu-24.04
if: github.actor != 'dependabot[bot]'
# Settle window (60) + the full gate (270) + margin.
timeout-minutes: 345
permissions:
# Pushes the freeze record to refs/notes/gstack-trains.
contents: write
# Dispatches the gate and reads train runs and their artifacts.
actions: write
checks: read
# Opens the issue a red backstop names.
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
# Read with git and jq only: without a train block nothing below runs.
- name: Read the train policy (main)
id: policy
run: |
train=$(git show "${GITHUB_SHA}:.gstack/ship-policy.json" 2>/dev/null | jq -c 'if (.train | type) == "object" then .train else null end' 2>/dev/null || true)
if [ -z "$train" ] || [ "$train" = null ]; then
echo "train=false" >> "$GITHUB_OUTPUT"
echo "- Backstop inert: main's .gstack/ship-policy.json declares no train block; per-PR gates cover main" >> "$GITHUB_STEP_SUMMARY"
else
echo "train=true" >> "$GITHUB_OUTPUT"
fi
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
if: steps.policy.outputs.train == 'true'
with:
bun-version: 1.4.2
- name: Gate main unless a green train covers its tree
if: steps.policy.outputs.train == 'true'
env:
GH_TOKEN: ${{ github.token }}
GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state
run: |
bun --no-install run scripts/train-backstop.ts run --repo "$GITHUB_REPOSITORY" --branch "$GITHUB_REF_NAME" --sha "$GITHUB_SHA" \
--summary "$GITHUB_STEP_SUMMARY"
+480 -27
View File
@@ -1,6 +1,8 @@
name: E2E Evals
# eval-bg (scripts/eval-bg.ts) finds its dispatch by the nonce in the run name (ENG-12).
run-name: ${{ inputs.nonce != '' && format('E2E Evals [eval-bg {0}]', inputs.nonce) || '' }}
# A dispatched screen's name also ends ` screen #<pr>`, which recover-receipts and
# gate-reuse filter on to list a PR's screens beside its pull_request runs.
run-name: "${{ inputs.nonce != '' && format('E2E Evals [eval-bg {0}]{1}', inputs.nonce, inputs.screen_pr != '' && format(' screen #{0}', inputs.screen_pr) || '') || '' }}"
on:
pull_request:
branches: [main]
@@ -30,9 +32,33 @@ on:
description: 'Same-repo PR number whose receipt store this run reads read-only (never saves); empty = fresh'
type: string
default: ''
# Merge trains (docs/ship-policy.md#run-a-merge-train): only under a train
# block in the default branch's .gstack/ship-policy.json. The route job
# validates them before any paid work (scripts/train-gate.ts route).
cases:
description: 'Train bisect: comma-separated case ids from the full gate plan (gstack-ship-queue train bisect sets it)'
type: string
default: ''
prefix_shas:
description: 'Train bisect: comma-separated prefix commits, each an ancestor of the train head on train/<nonce>'
type: string
default: ''
screen_pr:
description: 'Dispatched screen: the same-repo PR number (gstack-ship-queue ready --screen sets it)'
type: string
default: ''
screen_merge_sha:
description: "Dispatched screen: the PR's refs/pull/<n>/merge commit to check out and test"
type: string
default: ''
screen_policy:
description: 'Dispatched screen: narrow or broad (a broad screen never adopts a narrow run)'
type: string
default: ''
# A dispatched screen is keyed on its PR, so a newer head's screen cancels the older one.
concurrency:
group: evals-${{ github.event.pull_request.number || github.run_id }}
group: evals-${{ github.event.pull_request.number || (inputs.screen_pr != '' && format('screen-{0}', inputs.screen_pr)) || github.run_id }}
cancel-in-progress: true
env:
@@ -120,6 +146,129 @@ jobs:
docker manifest inspect "$EVAL_IMAGE" > /tmp/eval-runtime-manifest.json
echo "id=$(sha256sum /tmp/eval-runtime-manifest.json | cut -d ' ' -f1)" >> "$GITHUB_OUTPUT"
# ── Route (merge trains, GSTA-49 T1b; docs/ship-policy.md#run-a-merge-train)
# Names the run's mode before any paid work, from the train policy of the
# base revision (the PR's base commit, or the default branch for a
# dispatch). Without a `train` block every run routes as before: a
# pull_request run is `pr`, a dispatch is `dispatch`, and no other job
# changes. Under a train block a PR push is `pr-summary` (screen-summary
# prints the screening selection and its estimated cost; no paid slice
# starts), and gstack-ship-queue's dispatches are `screen`, `train` or
# `bisect` once scripts/train-gate.ts has validated their inputs. A refused
# dispatch fails here, before any paid work.
route:
runs-on: ubuntu-24.04
if: github.actor != 'dependabot[bot]'
timeout-minutes: 30
permissions:
contents: read
actions: read
pull-requests: read
outputs:
mode: ${{ steps.route.outputs.mode }}
record_artifact: ${{ steps.policy.outputs.record_artifact }}
full_gate_check: ${{ steps.policy.outputs.full_gate_check }}
screen_head: ${{ steps.screen.outputs.head_sha }}
screen_base: ${{ steps.screen.outputs.base_sha }}
screen_branch: ${{ steps.screen.outputs.head_branch }}
superseded: ${{ steps.screen.outputs.superseded }}
bisect_slices: ${{ steps.bisect.outputs.slices }}
bisect_timeouts: ${{ steps.bisect.outputs.slice_timeouts }}
bisect_refs: ${{ steps.bisect.outputs.prefix_refs }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
# Read with git and jq only: no repository code decides whether PR pushes start paid work.
- name: Read the train policy (base revision)
id: policy
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
base="${BASE_SHA:-origin/${DEFAULT_BRANCH}}"
train=$(git show "${base}:.gstack/ship-policy.json" 2>/dev/null | jq -c 'if (.train | type) == "object" then .train else null end' 2>/dev/null || true)
artifact=$(jq -r '.record_artifact // ""' <<<"${train:-null}" 2>/dev/null || true)
check=$(jq -r '.full_gate_check // ""' <<<"${train:-null}" 2>/dev/null || true)
if [ -z "$train" ] || [ "$train" = null ]; then
echo "train=false" >> "$GITHUB_OUTPUT"
echo "- Train policy: none at ${base}; this run routes as before" >> "$GITHUB_STEP_SUMMARY"
elif [[ "$artifact" =~ ^[A-Za-z0-9_.-]{1,64}$ ]] && [[ "$check" =~ ^[A-Za-z0-9_./-]{1,64}$ ]]; then
{ echo "train=true"; echo "record_artifact=${artifact}"; echo "full_gate_check=${check}"; } >> "$GITHUB_OUTPUT"
echo "- Train policy: declared at ${base} (record artifact ${artifact}, check ${check})" >> "$GITHUB_STEP_SUMMARY"
else
echo "train=false" >> "$GITHUB_OUTPUT"
echo "::warning::the train block at ${base} names no valid record_artifact and full_gate_check; this run routes as before"
fi
- name: Route this run
id: route
env:
TRAIN_POLICY: ${{ steps.policy.outputs.train }}
NONCE: ${{ inputs.nonce }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
EVALS_ALL: ${{ inputs.evals_all }}
CASES: ${{ inputs.cases }}
PREFIX_SHAS: ${{ inputs.prefix_shas }}
SCREEN_PR: ${{ inputs.screen_pr }}
SCREEN_MERGE_SHA: ${{ inputs.screen_merge_sha }}
SCREEN_POLICY: ${{ inputs.screen_policy }}
run: |
if [ "$GITHUB_EVENT_NAME" = pull_request ]; then
mode='pr'
if [ "$TRAIN_POLICY" = true ]; then mode=pr-summary; fi
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
echo "- Run mode: ${mode}" >> "$GITHUB_STEP_SUMMARY"
elif [ -f scripts/train-gate.ts ]; then
bun --no-install run scripts/train-gate.ts route --github-output "$GITHUB_OUTPUT" >> "$GITHUB_STEP_SUMMARY"
elif [ -n "${CASES}${PREFIX_SHAS}${SCREEN_PR}${SCREEN_MERGE_SHA}${SCREEN_POLICY}" ]; then
echo "::error::${GITHUB_REF_NAME} has no scripts/train-gate.ts to validate the train or screen inputs; dispatch from a ref that has it"
exit 1
else
echo "mode=dispatch" >> "$GITHUB_OUTPUT"
fi
# The screen's merge commit must merge the PR's current head into its
# base, and Free Tests must be green on that head (base-branch code: a
# screen dispatches on the default branch). No quiet window: a screen is
# an explicit request. A moved PR head reports superseded and plans nothing.
- name: Check the screen's PR head (Free Tests)
id: screen
if: steps.route.outputs.mode == 'screen'
env:
GH_TOKEN: ${{ github.token }}
SCREEN_PR: ${{ inputs.screen_pr }}
SCREEN_MERGE_SHA: ${{ inputs.screen_merge_sha }}
run: |
bun --no-install run scripts/eval-readiness.ts wait --repo "$GITHUB_REPOSITORY" --pr "$SCREEN_PR" --merge-sha "$SCREEN_MERGE_SHA" \
--run-id "$GITHUB_RUN_ID" --quiet-minutes 0 --deadline-minutes 20 \
--github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY"
- name: Plan the bisect (one executor per prefix)
id: bisect
if: steps.route.outputs.mode == 'bisect'
env:
CASES: ${{ steps.route.outputs.cases }}
PREFIXES: ${{ steps.route.outputs.prefixes }}
NONCE: ${{ inputs.nonce }}
run: |
bun --no-install run scripts/train-gate.ts bisect-plan --cases "$CASES" --prefixes "$PREFIXES" --nonce "$NONCE" \
--out /tmp/train-bisect/bisect.json --github-output "$GITHUB_OUTPUT" >> "$GITHUB_STEP_SUMMARY"
- name: Hand the bisect plan to its executors
if: steps.route.outputs.mode == 'bisect'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: train-bisect-plan
path: /tmp/train-bisect/bisect.json
retention-days: 30
# ── Readiness-gated dispatch (R1, GSTA-48) ───────────────────────────────
# Paid slices for a PR head start only after the PR has been quiet for the
# window (vars.EVALS_QUIET_MINUTES, default 10; the evals-no-debounce label
@@ -161,6 +310,35 @@ jobs:
--run-id "$GITHUB_RUN_ID" --quiet-minutes "$QUIET_MINUTES" --deadline-minutes 80 \
--github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY"
# GSTA-48 P3-1: a head the readiness job blocked runs no gate, so the PR's
# last "## E2E Evals" comment would still show an older head's verdict.
# Replace it (never post a new one) with what happened to this head. Runs no
# repository code.
readiness-comment:
runs-on: ubuntu-24.04
needs: [readiness]
if: ${{ !cancelled() && needs.readiness.result == 'failure' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
timeout-minutes: 5
permissions:
pull-requests: write
issues: write
steps:
- name: Replace a stale verdict comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
COMMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
--jq '.[] | select(.body | startswith("## E2E Evals")) | .id' | tail -1)
if [ -z "$COMMENT_ID" ]; then exit 0; fi
BODY="## E2E Evals: ⛔ NOT RUN on \`${HEAD_SHA}\`
The readiness job blocked this head (Free Tests did not pass on it, or did not finish within the readiness deadline), so no paid gate ran and no verdict covers it. An earlier verdict in this comment belonged to an older head.
Details: [run ${GITHUB_RUN_ID}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}). Fix and push, or re-run Free Tests and then re-run this workflow."
gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" -X PATCH -f body="$BODY"
# ── Receipt recovery (W1c: CEO-14/34, ENG-4/5/7) ─────────────────────────
# A run cancelled by the next push never reaches slices-report, so its
# finished receipts (passes AND the negatives executors write at the
@@ -174,10 +352,11 @@ jobs:
# plan-slices turns reuse off, so cases execute unreduced.
recover-receipts:
runs-on: ubuntu-24.04
needs: [readiness]
needs: [readiness, route]
# Runs after readiness settles, so a superseded run's cancellation has usually completed; a head whose Free Tests failed recovers nothing.
# A dispatch naming pr_receipts reads that PR's receipts from its runs' artifacts (cache entries are ref-scoped).
if: ${{ !cancelled() && (needs.readiness.result == 'success' || needs.readiness.result == 'skipped') && needs.readiness.outputs.superseded != 'true' && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && !contains(github.event.pull_request.labels.*.name, 'evals-fresh')) || (github.event_name == 'workflow_dispatch' && inputs.pr_receipts != '')) }}
# A dispatched screen only looks for a gate verdict to reuse (R2a/R2); a PR push under a train policy (pr-summary) skips this job.
if: ${{ !cancelled() && (needs.readiness.result == 'success' || needs.readiness.result == 'skipped') && needs.readiness.outputs.superseded != 'true' && needs.route.result == 'success' && needs.route.outputs.superseded != 'true' && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.route.outputs.mode == 'pr' && !contains(github.event.pull_request.labels.*.name, 'evals-fresh')) || (github.event_name == 'workflow_dispatch' && inputs.pr_receipts != '') || needs.route.outputs.mode == 'screen') }}
timeout-minutes: 5
permissions:
contents: read
@@ -185,7 +364,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.pull_request.base.sha || inputs.base_sha || github.event.repository.default_branch }}
ref: ${{ github.event.pull_request.base.sha || needs.route.outputs.screen_base || inputs.base_sha || github.event.repository.default_branch }}
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -202,6 +381,7 @@ jobs:
restore-keys: eval-input-v1-${{ github.repository_id }}-pr-${{ github.event.pull_request.number }}-
- name: Recover receipts of cancelled runs (base-ref code)
if: needs.route.outputs.mode != 'screen'
env:
GH_TOKEN: ${{ github.token }}
STORE_KEY: ${{ steps.store.outputs.cache-matched-key }}
@@ -229,26 +409,32 @@ jobs:
# identical. Anything else (a red, partial or cancelled gate, a changed
# own diff, a non-stamp byte, no binding or receipt, a gh or git error,
# evals-fresh) leaves no candidate: the full gate runs.
# A dispatched screen (GSTA-49 T1b) finds the same way, authenticated by
# PR number, head, repository and tested base (its merge commit's first
# parent), listing the PR's pull_request runs and earlier screens; a
# broad screen never adopts a narrow run.
- name: Find a gate verdict to reuse (R2a/R2, base-ref code)
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || needs.route.outputs.mode == 'screen'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.screen_pr }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
HEAD_BRANCH: ${{ github.event.pull_request.head.ref || needs.route.outputs.screen_branch }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || needs.route.outputs.screen_head }}
TESTED_SHA: ${{ inputs.screen_merge_sha || github.sha }}
SCREEN_POLICY: ${{ inputs.screen_policy }}
run: |
if [ ! -f scripts/gate-reuse.ts ]; then
echo "- Gate verdict reuse unavailable: the base ref has no scripts/gate-reuse.ts; the full gate runs" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
bun --no-install run scripts/gate-reuse.ts find --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --head-repo "$HEAD_REPO" \
--branch "$HEAD_BRANCH" --run-id "$GITHUB_RUN_ID" --head-sha "$HEAD_SHA" --tested-sha "$GITHUB_SHA" \
--out /tmp/gate-reuse --budget-seconds 90 >> "$GITHUB_STEP_SUMMARY" \
--branch "$HEAD_BRANCH" --run-id "$GITHUB_RUN_ID" --head-sha "$HEAD_SHA" --tested-sha "$TESTED_SHA" \
${SCREEN_POLICY:+--screen-policy "$SCREEN_POLICY"} --out /tmp/gate-reuse --budget-seconds 90 >> "$GITHUB_STEP_SUMMARY" \
|| echo "- Gate verdict reuse crashed; the full gate runs" >> "$GITHUB_STEP_SUMMARY"
- name: Hand a gate reuse candidate to plan-slices
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || needs.route.outputs.mode == 'screen'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: gate-reuse-candidate
@@ -264,6 +450,36 @@ jobs:
if-no-files-found: ignore
retention-days: 7
# ── PR push under a train policy (GSTA-49 T1b, a gate edit) ─────────────
# Only when the base revision declares a `train` block: the push starts no
# paid slice. This job prints how to request the paid screen, the
# screening selection and its estimated cost, for free; the train's full
# gate covers every merge. Without a train block it never runs.
screen-summary:
runs-on: ubuntu-24.04
needs: [route]
if: needs.route.outputs.mode == 'pr-summary' && github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Print the screening selection and its estimated cost (no paid case runs)
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
EVALS_TIER=gate bun --no-install run scripts/test-paid-shards.ts --tier gate --emit-plan /tmp/paid-plan/manifest.json --slices 1
bun --no-install run scripts/train-gate.ts screen-summary /tmp/paid-plan/manifest.json --pr "$PR_NUMBER" >> "$GITHUB_STEP_SUMMARY"
bun --no-install run scripts/test-pr-profile.ts summary /tmp/paid-plan/manifest.json >> "$GITHUB_STEP_SUMMARY"
# ── Sliced lane (the ONLY paid lane; legacy 17-row matrix deleted) ──────────
# One PLANNER computes diff selection + the slice plan ONCE (killing
# per-slice selector divergence); K executors consume the manifest; the
@@ -283,9 +499,10 @@ jobs:
# branch (see CLAUDE.md's garrytan-agents workflow).
plan-slices:
runs-on: ubicloud-standard-8
needs: [readiness, recover-receipts]
needs: [readiness, recover-receipts, route]
# !cancelled(): plans even when readiness or recovery was skipped (dispatch, evals-fresh) or recovery failed (reuse is then off); a superseded run, or a head whose readiness failed (Free Tests red), plans nothing.
if: ${{ !cancelled() && (needs.readiness.result == 'success' || needs.readiness.result == 'skipped') && needs.readiness.outputs.superseded != 'true' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
# The route job must have named a planned mode: pr-summary (a PR push under a train policy) and bisect plan nothing here, and neither does a refused dispatch.
if: ${{ !cancelled() && (needs.readiness.result == 'success' || needs.readiness.result == 'skipped') && needs.readiness.outputs.superseded != 'true' && needs.route.result == 'success' && needs.route.outputs.superseded != 'true' && contains(fromJSON('["pr","screen","train","dispatch"]'), needs.route.outputs.mode) && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
timeout-minutes: 10
permissions:
contents: read
@@ -293,9 +510,16 @@ jobs:
slices: ${{ steps.matrix.outputs.slices }}
slice_timeouts: ${{ steps.matrix.outputs.slice_timeouts }}
gate_reuse: ${{ steps.gate_reuse.outputs.gate_reuse }}
# The route, forwarded: the executors and the report read it from here.
mode: ${{ needs.route.outputs.mode }}
record_artifact: ${{ needs.route.outputs.record_artifact }}
full_gate_check: ${{ needs.route.outputs.full_gate_check }}
screen_head: ${{ needs.route.outputs.screen_head }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# A dispatched screen tests the PR's pinned refs/pull/<n>/merge commit; empty for every other run (its own ref).
ref: ${{ inputs.screen_merge_sha }}
# Preserve full history for merge-base diff selection. Moving the
# planner off the eval image must not change its selection inputs.
fetch-depth: 0
@@ -343,14 +567,14 @@ jobs:
env:
EVALS_ALL: ${{ (github.event_name == 'workflow_dispatch' && inputs.evals_all) && '1' || '' }}
EVALS_CACHE_DIR: ${{ steps.reuse.outputs.reuse == 'on' && '/tmp/gstack-eval-input-cache' || '' }}
# Dispatch diff base (CEO-17): the exact merge-base, else the named base branch, else detection.
EVALS_BASE: ${{ inputs.base_sha || (inputs.base_ref != '' && format('origin/{0}', inputs.base_ref)) || '' }}
# Dispatch diff base (CEO-17): a screen's merge-commit first parent, else the exact merge-base, else the named base branch, else detection.
EVALS_BASE: ${{ needs.route.outputs.screen_base || inputs.base_sha || (inputs.base_ref != '' && format('origin/{0}', inputs.base_ref)) || '' }}
run: EVALS_TIER=gate bun --no-install run scripts/test-paid-shards.ts --tier gate --emit-plan /tmp/paid-plan/manifest.json --slice-budget 420 --jobs 2 --max-parallel 20
# R2a/R2: reuse holds only when this head's own plan selects exactly the
# cases and trial plans the reused gate ran (selection fingerprint).
- name: Download a gate reuse candidate
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || needs.route.outputs.mode == 'screen'
continue-on-error: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
@@ -359,7 +583,7 @@ jobs:
- name: Confirm gate verdict reuse (R2a/R2)
id: gate_reuse
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || needs.route.outputs.mode == 'screen'
run: |
mkdir -p /tmp/gate-reuse
bun --no-install run scripts/gate-reuse.ts confirm /tmp/gate-reuse /tmp/paid-plan/manifest.json --out /tmp/paid-plan \
@@ -430,6 +654,8 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# A dispatched screen's pinned merge commit (the planner verified it); empty otherwise.
ref: ${{ inputs.screen_merge_sha }}
# Full history: files with SELF-derived selection (the LLM-judge
# map, routing) walk git at module load, and selection is
# fail-closed on git errors — a shallow checkout crashed those
@@ -555,6 +781,151 @@ jobs:
if-no-files-found: ignore
retention-days: 30
# ── Train bisect (GSTA-49 T1b) ─────────────────────────────────────────
# One executor per prefix of the train (route's validated prefix_shas, each
# an ancestor of the train head): every red case once, one trial, on that
# prefix's own checkout. Each writes the per-prefix record that
# `gstack-ship-queue train bisect` reads, as <record_artifact>-bisect-<sha12>.
# A bisect never reuses a result and never binds a verdict.
train-bisect:
runs-on: ubicloud-standard-8
needs: [build-image, route]
if: ${{ !cancelled() && needs.build-image.result == 'success' && needs.route.result == 'success' && needs.route.outputs.mode == 'bisect' }}
env:
EVALS_RUN_ID: ci-${{ github.run_id }}-${{ github.run_attempt }}-train-bisect-${{ matrix.slice }}
timeout-minutes: ${{ fromJSON(needs.route.outputs.bisect_timeouts)[matrix.slice] }}
permissions:
contents: read
packages: read
container:
image: ${{ needs.build-image.outputs.image-tag }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
options: --user runner
strategy:
fail-fast: false
max-parallel: 7
matrix:
slice: ${{ fromJSON(needs.route.outputs.bisect_slices) }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ fromJSON(needs.route.outputs.bisect_refs)[matrix.slice] }}
fetch-depth: 0
persist-credentials: false
- name: Verify the prefix checkout
id: prefix
env:
PREFIX_SHA: ${{ fromJSON(needs.route.outputs.bisect_refs)[matrix.slice] }}
run: |
actual=$(git rev-parse HEAD)
if [ "$actual" != "$PREFIX_SHA" ]; then
echo "::error::train-bisect checked out ${actual}, not its prefix ${PREFIX_SHA}"
exit 1
fi
echo "short=${PREFIX_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Fix bun temp
uses: ./.github/actions/fix-bun-temp
- name: Restore deps
uses: ./.github/actions/restore-deps
- run: bun run build
# Any slice can host a PTY smoke, so the seed/registration steps run
# UNCONDITIONALLY (both are idempotent) — the old matrix keyed them on
# matrix.suite.name, which a sliced lane cannot do. The register
# composite carries the fail-fast dangling-symlink/frontmatter
# verification loop, so a moved skill target fails HERE in seconds,
# not as a wedged PTY session at the shard wall.
- name: Seed claude interactive config
uses: ./.github/actions/seed-claude-config
with:
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Register gstack skills for PTY smokes
uses: ./.github/actions/register-gstack-skills
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: train-bisect-plan
path: /tmp/train-bisect
- name: Authenticate the Codex CLI
shell: bash
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
codex="${GSTACK_CI_CODEX_BIN_DIR:?}/codex"
export CODEX_HOME="$HOME/.gstack-ci-codex"
mkdir -p "$CODEX_HOME"
echo "GSTACK_CI_CODEX_HOME=$CODEX_HOME" >> "$GITHUB_ENV"
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "::warning::OPENAI_API_KEY is unavailable; Codex eval cases cannot authenticate and will fail."
exit 0
fi
printenv OPENAI_API_KEY | "$codex" login --with-api-key
"$codex" login status >/dev/null 2>&1
- name: Run the red cases on prefix ${{ matrix.slice }}
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-browsers
EVALS_JOBS: "1"
EVALS_CONCURRENCY: "1"
SLICE: ${{ matrix.slice }}
run: EVALS_TIER=gate bun run scripts/train-gate.ts bisect-prefix --plan /tmp/train-bisect/bisect.json --index "$SLICE" --out /tmp/train-bisect/out
- name: Upload the prefix record
if: always() && steps.prefix.outputs.short != ''
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: ${{ needs.route.outputs.record_artifact }}-bisect-${{ steps.prefix.outputs.short }}
path: /tmp/train-bisect/out/bisect-prefix.json
if-no-files-found: ignore
overwrite: true
retention-days: 90
- name: Upload native capture evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: native-captures-${{ env.EVALS_RUN_ID }}
include-hidden-files: true
path: |
~/.gstack/projects/*/e2e-runs
~/.gstack/projects/*/evals/qa-callers
~/.gstack-dev/e2e-runs
~/.gstack-dev/evals/qa-callers
if-no-files-found: ignore
retention-days: 90
# The spooled per-shard full logs — a red weekly/PR lane three weeks
# later needs more than a summary line.
# always(), not failure(): a failed behavior trial is a verdict and no
# longer reds its runner, but its full log is the diagnosis evidence.
- name: Upload shard logs
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: train-bisect-logs-${{ matrix.slice }}-a${{ github.run_attempt }}
include-hidden-files: true
# The Fix-bun-temp step points TMPDIR at /home/runner/.cache, so the
# runner's spool lands THERE, not /tmp — the original /tmp glob
# uploaded nothing and a red slice's diagnostics were unreachable.
path: |
/home/runner/.cache/gstack-paid-shard-*.log
/tmp/gstack-paid-shard-*.log
if-no-files-found: ignore
retention-days: 30
slices-report:
# GitHub-hosted, like recover-receipts: Ubicloud runners send actions/cache
# to Ubicloud's own transparent cache, which a GitHub-hosted restore never
@@ -582,6 +953,10 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# A screen's pinned merge commit (empty otherwise); a train run needs the
# history of its prefix commits T1..Tk for the train record.
ref: ${{ inputs.screen_merge_sha }}
fetch-depth: ${{ needs.plan-slices.outputs.mode == 'train' && '0' || '1' }}
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -671,19 +1046,24 @@ jobs:
# R2a/R2: what this executed run proved, read by a later push. The base
# is the tested merge commit's first parent. Reused runs bind nothing,
# so reuse never chains off a reuse.
# A dispatched screen binds too (purpose screen, its tested merge commit
# and policy). Train, bisect and other dispatches never bind for reuse.
- name: Bind this run's verdict for reuse (R2a/R2)
if: always() && github.event_name == 'pull_request' && needs.plan-slices.outputs.gate_reuse != 'true'
if: always() && (github.event_name == 'pull_request' || needs.plan-slices.outputs.mode == 'screen') && needs.plan-slices.outputs.gate_reuse != 'true'
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.screen_pr }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || needs.plan-slices.outputs.screen_head }}
TESTED_SHA: ${{ inputs.screen_merge_sha || github.sha }}
SCREEN_POLICY: ${{ inputs.screen_policy }}
RECONCILE_EXIT: ${{ steps.reconcile.outputs.exit }}
run: |
bun --no-install run scripts/gate-reuse.ts bind /tmp/paid-report --out /tmp/gate-binding --repo "$GITHUB_REPOSITORY" \
--pr "$PR_NUMBER" --run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" --head-sha "$HEAD_SHA" \
--tested-sha "$GITHUB_SHA" --profile "$EVALS_PROFILE" --reconcile-exit "$RECONCILE_EXIT" >> "$GITHUB_STEP_SUMMARY"
--tested-sha "$TESTED_SHA" --profile "$EVALS_PROFILE" --reconcile-exit "$RECONCILE_EXIT" \
${SCREEN_POLICY:+--purpose screen --screen-policy "$SCREEN_POLICY"} >> "$GITHUB_STEP_SUMMARY"
- name: Upload the gate binding
if: always() && github.event_name == 'pull_request' && needs.plan-slices.outputs.gate_reuse != 'true'
if: always() && (github.event_name == 'pull_request' || needs.plan-slices.outputs.mode == 'screen') && needs.plan-slices.outputs.gate_reuse != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: gate-binding-a${{ github.run_attempt }}
@@ -700,17 +1080,81 @@ jobs:
if-no-files-found: ignore
retention-days: 90
# GSTA-49 T1b: a train's own gate run publishes its record, read by
# gstack-ship-queue's verdict and verify and by the main backstop: the
# census from this run's plan manifest, one panel per planned case, and
# the prefix trees T1..Tk modulo stamps. GREEN only for a complete census.
- name: Write the train record
if: always() && needs.plan-slices.outputs.mode == 'train'
env:
NONCE: ${{ inputs.nonce }}
HEAD_SHA: ${{ inputs.expected_sha }}
RECORD: ${{ needs.plan-slices.outputs.record_artifact }}
RECONCILE_EXIT: ${{ steps.reconcile.outputs.exit }}
run: |
bun --no-install run scripts/train-gate.ts train-record /tmp/paid-report --nonce "$NONCE" --head "$HEAD_SHA" \
--reconcile-exit "$RECONCILE_EXIT" --run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" --repo "$GITHUB_REPOSITORY" \
--out "/tmp/train-record/${RECORD}.json" >> "$GITHUB_STEP_SUMMARY"
- name: Upload the train record
if: always() && needs.plan-slices.outputs.mode == 'train'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: ${{ needs.plan-slices.outputs.record_artifact }}
path: /tmp/train-record
if-no-files-found: ignore
overwrite: true
retention-days: 90
- name: Fail the workflow when reconciliation failed
if: (steps.reconcile.outputs.exit || steps.reused.outputs.exit) != '0'
run: exit 1
# GSTA-49 T1b: the check run gstack-ship-queue requires on the train head,
# published only for a complete GREEN census. Its own job with checks:
# write and no repository code (the same split as slices-comment).
train-check:
runs-on: ubuntu-24.04
needs: [plan-slices, slices-report]
if: ${{ !cancelled() && needs.plan-slices.outputs.mode == 'train' && needs.slices-report.result == 'success' }}
timeout-minutes: 5
permissions:
checks: write
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: ${{ needs.plan-slices.outputs.record_artifact }}
path: /tmp/train-record
- name: Publish the full-gate check on the train head
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHECK: ${{ needs.plan-slices.outputs.full_gate_check }}
RECORD: ${{ needs.plan-slices.outputs.record_artifact }}
NONCE: ${{ inputs.nonce }}
HEAD_SHA: ${{ inputs.expected_sha }}
run: |
record="/tmp/train-record/${RECORD}.json"
if ! jq -e --arg nonce "$NONCE" --arg head "$HEAD_SHA" \
'.schema == 1 and .kind == "train-gate" and .verdict == "GREEN" and .census.complete == true and .nonce == $nonce and .head_sha == $head' \
"$record" >/dev/null 2>&1; then
echo "::error::the train record is not a complete GREEN census of ${HEAD_SHA} (nonce ${NONCE}); no ${CHECK} check run"
exit 1
fi
summary=$(jq -r '"rule \(.census.executed.rule)/\(.census.planned.rule), judge \(.census.executed.judge)/\(.census.planned.judge); prefixes T1..T\(.prefixes | length)"' "$record")
gh api "repos/${GITHUB_REPOSITORY}/check-runs" -X POST -f name="$CHECK" -f head_sha="$HEAD_SHA" -f status=completed -f conclusion=success \
-f details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
-f "output[title]=Train ${NONCE}: complete GREEN census" -f "output[summary]=${summary}" >/dev/null
echo "- ${CHECK}: success on ${HEAD_SHA} (${summary})" >> "$GITHUB_STEP_SUMMARY"
# PR comment in its OWN job with the write token and ZERO repo code: no
# checkout, no bun install — only downloaded artifacts, jq, and gh. See the
# trust-boundary note on slices-report.
slices-comment:
runs-on: ubicloud-standard-2
needs: slices-report
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.slices-report.result != 'skipped' }}
# A dispatched screen comments on its PR too (screen_pr was validated by the route job, or nothing ran).
if: ${{ !cancelled() && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || inputs.screen_pr != '') && needs.slices-report.result != 'skipped' }}
timeout-minutes: 5
permissions:
pull-requests: write
@@ -739,6 +1183,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RECONCILE_EXIT: ${{ needs.slices-report.outputs.reconcile-exit }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.screen_pr }}
SCREEN_POLICY: ${{ inputs.screen_policy }}
SCREEN_MERGE_SHA: ${{ inputs.screen_merge_sha }}
run: |
# shellcheck disable=SC2086,SC2059
# R2a/R2: a confirmed verdict reuse ran no case; say which run,
@@ -845,7 +1292,13 @@ jobs:
fi
COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
if [ -n "$SCREEN_POLICY" ]; then
BODY="${BODY}
*Dispatched ${SCREEN_POLICY} screen (gstack-ship-queue ready --screen) of merge commit \`${SCREEN_MERGE_SHA}\`.*"
fi
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.body | startswith("## E2E Evals")) | .id' | tail -1)
if [ -n "$COMMENT_ID" ]; then
@@ -855,6 +1308,6 @@ jobs:
# REST, not gh's pr-comment subcommand: this job runs with NO
# checkout (the token/exec split), and that subcommand resolves
# the repo FROM git — it dies with "not a git repository" here.
gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
-X POST -f body="$BODY"
fi