chore(e2e): review screenshots for #2703 #11242
Workflow file for this run
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: CI | |
| on: | |
| push: | |
| # `main` is the default branch and the integration target: day-to-day PRs | |
| # land there under the LIGHT tier. `release` only ever receives promotion | |
| # PRs from `main`, and those run the FULL tier — so `release` is always in a | |
| # state a release can be cut from. See the heavy-tier guards on `bench` / | |
| # `e2e` below, and docs/testing-strategy.md. | |
| branches: [release, main] | |
| # A release tag must receive this exact same aggregate gate before the | |
| # publishing workflow will package it. See release-mac.yml's preflight. | |
| tags: ['v*'] | |
| # `labeled` is included so adding the `update-screenshots` or `ci-full` label | |
| # kicks a fresh run on the PR head — plan-e2e then forces mode=full | |
| # (`update-screenshots` additionally renders every reference shot into an | |
| # immutable candidate artifact). Without it the label only takes effect on the | |
| # next push/sync, so a label-only request would never regenerate. (For | |
| # pull_request events GitHub reads these triggers from the BASE branch's | |
| # workflow, so this is only active once merged to main.) | |
| pull_request: | |
| # `ready_for_review` is required because the heavy tier (build / e2e / bench) | |
| # is gated on `draft == false` below: without it, promoting a draft would | |
| # never dispatch those jobs and `CI Passed` would sit on the draft's | |
| # heavy-jobs-skipped result forever. | |
| types: [opened, synchronize, reopened, labeled, ready_for_review] | |
| # No `branches:` filter — PRs into `main` and the promotion PRs into | |
| # `release` both run this workflow. Which TIER they get is decided per job by | |
| # `github.base_ref`, not here, so a PR retargeted from `main` to `release` | |
| # picks up the full tier on its next sync without touching this file. | |
| # Nightly full e2e on GitHub-hosted runners — the comprehensive safety net. | |
| # Merge-eligible PRs run either the oracle-selected subset or, when the oracle | |
| # cannot safely thin, the full suite. Main pushes remain cheap because their | |
| # exact commit was already gated as a PR; the nightly catches direct-push and | |
| # environment drift. Off-:00 minute to avoid the cron stampede. | |
| schedule: | |
| - cron: '17 6 * * *' | |
| # NOTE: there is deliberately no `merge_group` trigger. GitHub's merge queue | |
| # requires Enterprise Cloud for private repositories and this org is on Team, | |
| # so the trigger could never fire. The `main` -> `release` promotion flow is | |
| # the stand-in: `main` absorbs the day's merges under the light tier, and | |
| # the promotion PR runs the full tier once for the whole batch. What we give | |
| # up versus a real queue is automatic bisection — a red promotion names a | |
| # batch, not a commit — which is why promotions should be frequent enough to | |
| # stay small. | |
| concurrency: | |
| group: ci-${{ github.event.pull_request.number || github.ref }} | |
| # Nightly schedule must not cancel an in-flight tip push/PR: cron can fire | |
| # hours late, and schedule+push share `ci-refs/heads/main`. They also use | |
| # different e2e runners (hosted vs self-hosted), so running in parallel is | |
| # fine. A new push/PR sync still cancels older runs in the group. | |
| cancel-in-progress: ${{ github.event_name != 'schedule' }} | |
| jobs: | |
| # Job timeouts: every job below sets `timeout-minutes`, and | |
| # scripts/ci-workflow-invariants.test.ts fails the build if a new one forgets. | |
| # GitHub's default is 360 minutes, which is survivable on hosted runners (you | |
| # pay for it) and is not on this fleet: runners are EPHEMERAL and serve both | |
| # tiers, so one wedged job parks a whole runner — a sizeable slice of total | |
| # capacity — for six hours while every other PR queues behind it. Observed on | |
| # a `main` push that sat at 167 minutes with no job-level cap to stop it. | |
| # | |
| # The caps are leak-stoppers, NOT latency SLAs. Each is roughly 3-5x the | |
| # observed duration so that a slow-but-healthy run (cold dependency cache, a | |
| # lint OOM retry, a saturated box) never goes red on the clock — a false red | |
| # costs more than the leak it would prevent. Queue time is excluded: Actions | |
| # starts this clock when the job begins executing, not when it is dispatched. | |
| # Tighten only with data (a run record showing the real p99), and raise | |
| # rather than let a genuinely-slower job flake. | |
| # | |
| # Check-tier runner routing: GitHub-hosted is the DEFAULT, and the org | |
| # self-hosted fleet is opt-in. Every `runs-on` below (except e2e, which routes | |
| # by label with its own nightly-hosted exception, and release-mac in its own | |
| # workflow) reads the SELF_HOSTED_CHECKS Actions variable: | |
| # (unset or '') -> ubuntu-latest <- default | |
| # SELF_HOSTED_CHECKS=copse-checks -> the fleet (ci-runners/) | |
| # | |
| # This inverts the old CHECKS_RUNNER scheme, which defaulted to whatever a | |
| # variable said and treated hosted as the fallback. Two things changed when | |
| # this repository went public: | |
| # 1. Standard GitHub-hosted minutes are free and unlimited on public repos, | |
| # so the cost argument that justified the fleet is gone. | |
| # 2. Public repos get 4-vCPU / 16 GiB hosted runners — more than double the | |
| # 6 GB the self-hosted check cgroup allows. Hosted is now the BIGGER box, | |
| # not the fallback one. | |
| # The rename is load-bearing, not cosmetic: `vars.X` resolves repo-then-org, | |
| # so an org-level CHECKS_RUNNER=copse-checks used to re-route this repo's | |
| # whole check tier with no change here and no signal on the PR. Reading a name | |
| # that is set nowhere makes hosted the default in code rather than in a | |
| # variable someone else can flip. | |
| # | |
| # Opting a tier back onto the fleet is therefore deliberate: | |
| # gh variable set SELF_HOSTED_CHECKS --repo copse-dev/agent-pane --body copse-checks | |
| # gh variable delete SELF_HOSTED_CHECKS --repo copse-dev/agent-pane | |
| # (or Settings -> Secrets and variables -> Actions -> Variables.) Nothing | |
| # auto-detects the fleet going offline, so if you set it and the pool has no | |
| # online runner, routed jobs queue until a runner returns or the variable is | |
| # cleared — clear it when taking the fleet down. | |
| # | |
| # Fork PRs run only the `precheck` safe tier, forced onto a GitHub-hosted | |
| # runner with a read-only token. Every heavier job that executes repository | |
| # code keeps its same-repo `if` guard, so untrusted commits never run on the | |
| # self-hosted fleet. | |
| # Cheap static gate + e2e planning. Runs the fast static checks (typecheck, | |
| # lint, format, dead-code, oracle liveness) AND computes the e2e oracle plan in | |
| # a single job. Everything expensive — coverage, build, e2e — hangs off this | |
| # via `needs: precheck`, so a typo, lint error, or bad format short-circuits | |
| # the pipeline before it burns runner minutes on the build + e2e shards. | |
| # Folding the former standalone `plan-e2e` job in here drops a duplicate | |
| # checkout + node setup (and its rounded-up billed minute) from every run. | |
| precheck: | |
| # GitHub-hosted by default; the self-hosted check fleet is opt-in via | |
| # SELF_HOSTED_CHECKS (see the routing note above). | |
| # History: the 6 GB self-hosted cgroup has cgroup-killed `tsc` / `eslint .` | |
| # under load (exit 137), which is why the steps below retry on 137. On the | |
| # public-repo hosted runner (16 GiB) that headroom is no longer scarce; the | |
| # retries stay because opting back onto the fleet must not silently regress. | |
| # | |
| # Fork PRs are pinned to GitHub-hosted: this is the one job that executes | |
| # fork code (typecheck/lint run the PR's package scripts and plugins), and | |
| # it must never reach the self-hosted fleet — same invariant gitleaks.yml | |
| # states for its standalone fork scan. Hosted minutes are free once the | |
| # repository is public, so this costs nothing on the runs it applies to. | |
| runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'ubuntu-latest' || vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }} | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| # Consumed by the e2e job: how much of the e2e tier this run needs (mode/ | |
| # specs), and the runner-dependent shard fan-out (e2e_shards / shard_total). | |
| outputs: | |
| mode: ${{ steps.plan.outputs.mode }} | |
| specs: ${{ steps.plan.outputs.specs }} | |
| e2e_shards: ${{ steps.shards.outputs.list }} | |
| e2e_shard_total: ${{ steps.shards.outputs.total }} | |
| # How much of the UNIT tier the `check` job below needs. Empty is read as | |
| # `full` there, so a branch of the plan step that forgets to set it fails | |
| # safe rather than silently thinning the suite. | |
| unit_mode: ${{ steps.plan.outputs.unit_mode }} | |
| unit_specs: ${{ steps.plan.outputs.unit_specs }} | |
| steps: | |
| # fetch-depth: 0 so the e2e oracle (plan step) can diff against the PR base | |
| # / previous main tip. | |
| - uses: actions/checkout@v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| # Do not leave even the read-only GITHUB_TOKEN in a fork checkout. | |
| # Same-repo runs keep credentials for the oracle's base ref fetches. | |
| persist-credentials: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} | |
| # Same-repository PRs already execute inside this job's check-fleet trust | |
| # boundary. Scan them here so secret detection blocks the required | |
| # `CI Passed` gate without dispatching a second GitHub-hosted job. Forks | |
| # remain on the standalone hosted workflow in gitleaks.yml. | |
| - name: Install pinned gitleaks CLI | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | |
| env: | |
| GITLEAKS_VERSION: 8.30.1 | |
| GITLEAKS_LINUX_X64_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| archive="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | |
| path="${RUNNER_TEMP}/${archive}" | |
| curl --fail --location --retry 3 \ | |
| --output "${path}" \ | |
| "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${archive}" | |
| echo "${GITLEAKS_LINUX_X64_SHA256} ${path}" | sha256sum --check --strict | |
| tar -xzf "${path}" -C "${RUNNER_TEMP}" gitleaks | |
| "${RUNNER_TEMP}/gitleaks" version | |
| - name: Scan repository history for secrets | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | |
| run: | | |
| "${RUNNER_TEMP}/gitleaks" git --redact --verbose --no-banner \ | |
| --log-opts="--full-history --diff-filter=tuxdb HEAD" . | |
| - uses: ./.github/actions/setup | |
| # Retry once on exit 137 (SIGKILL / cgroup OOM). The check fleet caps each | |
| # runner at ~6 GB; under load, tsc/eslint can get killed even though a | |
| # second attempt on a quieter container succeeds. Real type/lint errors | |
| # exit non-137 and still fail the gate immediately. | |
| - name: Typecheck | |
| run: | | |
| set +e | |
| npm run typecheck | |
| status=$? | |
| set -e | |
| if [ "$status" -eq 0 ]; then exit 0; fi | |
| if [ "$status" -ne 137 ]; then exit "$status"; fi | |
| echo "::warning::typecheck OOM-killed (137); retrying once" | |
| sleep 10 | |
| npm run typecheck | |
| - name: Lint | |
| run: | | |
| # Typecheck just exited; give the cgroup a beat to reclaim before | |
| # lint starts, then retry a couple of times on 137. The lint script | |
| # runs typed projects sequentially so their programs do not share one | |
| # V8 heap; a retry is still useful for external cgroup pressure. | |
| sleep 15 | |
| attempt=1 | |
| while true; do | |
| set +e | |
| npm run lint | |
| status=$? | |
| set -e | |
| if [ "$status" -eq 0 ]; then exit 0; fi | |
| if [ "$status" -ne 137 ] || [ "$attempt" -ge 3 ]; then exit "$status"; fi | |
| echo "::warning::lint OOM-killed (137); retry $attempt/3" | |
| attempt=$((attempt + 1)) | |
| sleep 20 | |
| done | |
| - run: npm run format:check | |
| - run: npm run check:dead-code | |
| # Enforce the API protocol's compatibility rule (docs/api-protocol.md). | |
| # The unit test only pins that the committed manifest matches the sources, | |
| # which a breaking change satisfies by regenerating; this is the step that | |
| # reads the change against the base and fails a breaking one that did not | |
| # bump API_PROTOCOL_VERSION. Only meaningful with a base to compare to, | |
| # so it is skipped on schedule / branch-create runs. | |
| - name: API protocol compatibility | |
| env: | |
| # PR base on pull_request; previous tip on push. Empty for schedule. | |
| BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} | |
| run: | | |
| set -euo pipefail | |
| # No usable base (schedule, first push, branch create) -> nothing to | |
| # compare against. Skipping here is safe: the PR that introduces a | |
| # change always has one. | |
| if [ -z "${BASE_SHA:-}" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then | |
| echo "No base sha for ${GITHUB_EVENT_NAME} -> skipping the API protocol comparison" | |
| exit 0 | |
| fi | |
| # A base that cannot be read fails the step rather than skipping it: | |
| # a compatibility gate that passes whenever CI cannot see the base is | |
| # worse than no gate. The script distinguishes that from a base that | |
| # genuinely predates the protocol, which it allows. | |
| git fetch --no-tags --quiet origin "$BASE_SHA" 2>/dev/null || true | |
| node scripts/gen-api-protocol.mts --compare-ref "$BASE_SHA" | |
| # Fail closed on high/critical dependency advisories. This runs after the | |
| # lockfile-exact setup, so it audits precisely the tree CI will execute. | |
| # The registry's audit endpoint intermittently times out from hosted | |
| # runners (2026-09-04: three attempts in a row here while unrelated | |
| # branches passed either side of them), and pnpm's own two retries span | |
| # too short a window. Retry only when the registry could not be reached, | |
| # so a real advisory still fails on the first attempt. | |
| - run: | | |
| attempt=1 | |
| while true; do | |
| set +e | |
| pnpm audit --audit-level=high 2>&1 | tee "$RUNNER_TEMP/pnpm-audit.log" | |
| status=${PIPESTATUS[0]} | |
| set -e | |
| if [ "$status" -eq 0 ]; then exit 0; fi | |
| if ! grep -qE 'ERR_SOCKET_TIMEOUT|FetchError|ECONNRESET|ETIMEDOUT' "$RUNNER_TEMP/pnpm-audit.log" || [ "$attempt" -ge 3 ]; then | |
| exit "$status" | |
| fi | |
| echo "::warning::pnpm audit could not reach the registry; retry $attempt/3" | |
| attempt=$((attempt + 1)) | |
| sleep 60 | |
| done | |
| # Guard the e2e oracle that gates the `e2e` job: every spec must stay | |
| # selectable (liveness) and the mapping invariants must hold, so the gate | |
| # can't silently start skipping specs it should run. | |
| - run: npm run check:oracle | |
| # Test oracle (scripts/test-oracle.mts) decides how much of the e2e tier | |
| # this run needs. On a pull_request it maps the diff (vs the PR base) to the | |
| # specs it can affect; on a push to main it maps vs the previous main | |
| # commit. The nightly `schedule` always runs the full suite. The output | |
| # drives the `e2e` job below: | |
| # mode=full → all 8 shards run the whole CI suite | |
| # mode=subset → only the listed specs run, distributed across the shards | |
| # mode=skip → nothing e2e-relevant changed; shards report green, no run | |
| # subset is used only for confident, UI-coupled diffs; LOW-confidence | |
| # (backend logic with no selector coupling) and broad changes fall back to | |
| # full. Full coverage on every push is no longer guaranteed — the nightly | |
| # is the net. | |
| # | |
| # The same call also emits a UNIT plan (`unit_mode` / `unit_specs`) that the | |
| # `check` job below applies, but ONLY to a PR stacked on another PR's branch | |
| # — see that job for why. Every early-exit branch here predates the oracle's | |
| # analysis of the diff, so each one emits `unit_mode=full`: only the oracle | |
| # path has looked at what actually changed, so only it may thin. | |
| - id: plan | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| BASE_REF: ${{ github.base_ref }} | |
| # PR base on pull_request; previous-tip on push. Empty for schedule. | |
| BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} | |
| # Real PR head commit (not the merge ref this job checks out), used to | |
| # avoid rerunning e2e after a human commits only reviewed screenshots. | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| # `update-screenshots` forces a full e2e run so every reference shot is | |
| # re-rendered into the candidate artifact, even when the oracle would | |
| # otherwise thin (subset) or skip the suite. | |
| UPDATE_SCREENSHOTS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'update-screenshots') }} | |
| # `ci-full` forces the full e2e suite, bypassing the oracle's thinning | |
| # entirely — for when a change wants exhaustive coverage NOW rather | |
| # than waiting for the nightly net (e.g. infra/runner changes whose | |
| # blast radius the selector-vocabulary map cannot see). | |
| CI_FULL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'ci-full') }} | |
| run: | | |
| if [ "$EVENT" = "schedule" ]; then | |
| echo "Nightly schedule -> full e2e suite" | |
| { echo "mode=full"; echo "specs="; echo "unit_mode=full"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Screenshot-only head commit -> skip e2e. A human accepting a candidate | |
| # artifact already reviewed the parent run's output, and the commit adds | |
| # only reference PNGs, so re-running the suite would render the same shots | |
| # again. Keep this before the label checks so a still-present request label | |
| # does not turn the reviewed follow-up into another expensive full run. | |
| if [ -n "$HEAD_SHA" ]; then | |
| changed="$(git diff --name-only "${HEAD_SHA}^" "${HEAD_SHA}" 2>/dev/null || true)" | |
| if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qv '^tests/e2e/screenshots/'; then | |
| echo "HEAD commit only refreshes reference screenshots -> skip e2e" | |
| { echo "mode=skip"; echo "specs="; echo "unit_mode=full"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| fi | |
| # A promotion is the release gate for the whole trunk batch. Always | |
| # exercise the complete e2e suite here rather than allowing the oracle | |
| # to thin or skip it. Keep this after the screenshot-only escape above: | |
| # a reference-only follow-up already has immutable evidence from its | |
| # parent and does not need another full render. | |
| if [ "$EVENT" = "pull_request" ] && [ "$BASE_REF" = "release" ]; then | |
| echo "PR targets release -> full e2e suite (promotion gate)" | |
| { echo "mode=full"; echo "specs="; echo "unit_mode=full"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$UPDATE_SCREENSHOTS_LABEL" = "true" ]; then | |
| echo "update-screenshots label -> full e2e suite (force screenshot refresh)" | |
| { echo "mode=full"; echo "specs="; echo "unit_mode=full"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$CI_FULL_LABEL" = "true" ]; then | |
| echo "ci-full label -> full e2e suite (oracle bypassed on request)" | |
| { echo "mode=full"; echo "specs="; echo "unit_mode=full"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # No usable base (first push / forced push / branch create) -> full. | |
| if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then | |
| echo "No base sha for $EVENT -> full e2e suite" | |
| { echo "mode=full"; echo "specs="; echo "unit_mode=full"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # full clone already has base history; this is belt-and-suspenders. | |
| git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || true | |
| node scripts/test-oracle.mts --plan --base "$BASE_SHA" | tee plan.txt | |
| grep -E '^(mode|specs|unit_mode|unit_specs)=' plan.txt >> "$GITHUB_OUTPUT" | |
| { echo '### e2e oracle plan'; echo '```'; cat plan.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY" | |
| # Advisory: annotate which reference shots a UI change will refresh. Stale | |
| # shots DON'T block the PR — the `e2e` job below already re-renders them as a | |
| # side effect of the gate run, and `screenshot-artifacts` preserves the diff | |
| # for explicit review. Pure static analysis — no build, Electron, or rerun. | |
| # PR-only: no label exists for push/schedule. | |
| - name: Plan reference-screenshot regeneration | |
| if: github.event_name == 'pull_request' | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| UPDATE_SCREENSHOTS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'update-screenshots') }} | |
| run: | | |
| git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || true | |
| node scripts/check-screenshots.mts --plan --base "$BASE_SHA" | |
| # Keep every full e2e run at 8 shards. This was sized when both runner | |
| # classes had a tight memory ceiling: the private-repo hosted runner was | |
| # 7 GB and each self-hosted `copse-e2e` container is capped at 6 GB. Public | |
| # repos now get 4-vCPU / 16 GiB hosted runners, so on the default runner | |
| # this fan-out is conservative — retune it against a run record rather | |
| # than by assuming the headroom, and keep it safe for a fleet opt-in. | |
| # Packing the suite into 3 shards makes each | |
| # container launch too many sequential Electron sessions; accumulated | |
| # Electron/gortex processes then OOM-crash unrelated late-running specs. | |
| # `--shard` and the subset round-robin still partition specs disjointly. | |
| - id: shards | |
| env: | |
| PLAN_MODE: ${{ steps.plan.outputs.mode }} | |
| PLAN_SPECS: ${{ steps.plan.outputs.specs }} | |
| run: | | |
| # Size the matrix to the work the oracle actually planned. Previously | |
| # this was always 8, so a screenshots-only commit (mode=skip) still | |
| # dispatched 8 shards that each paid checkout + `npm ci` + dist | |
| # download before hitting the early-exit in the run step — 8 self-hosted | |
| # runners occupied to do nothing, on every bot screenshot push. Same for | |
| # a subset smaller than 8: the surplus shards booted only to find an | |
| # empty slice. | |
| # | |
| # An empty list must NOT reach the `e2e` matrix: GitHub Actions treats | |
| # `strategy.matrix: []` as a job **failure**, not 'skipped' (observed on | |
| # #1233 — `needs.e2e.result=failure` painted `CI Passed` red in skip | |
| # mode). The `e2e` job `if:` therefore gates on `e2e_shard_total != 0` | |
| # so a zero-shard plan skips the job cleanly; `ci-passed` then sees | |
| # 'skipped' and its mode=skip branch accepts it. | |
| if [ "$PLAN_MODE" = "skip" ]; then | |
| echo "mode=skip -> no e2e shards dispatched" | |
| { echo "total=0"; echo "list=[]"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$PLAN_MODE" = "subset" ]; then | |
| # SHARD_TOTAL is the round-robin modulus in the run step, so total and | |
| # the list must agree or specs would be assigned to shards that were | |
| # never dispatched. Cap at 8 (the memory ceiling from #339). | |
| n=0 | |
| for _ in $PLAN_SPECS; do n=$(( n + 1 )); done | |
| [ "$n" -gt 8 ] && n=8 | |
| if [ "$n" -lt 1 ]; then | |
| echo "mode=subset with no specs -> no e2e shards dispatched" | |
| { echo "total=0"; echo "list=[]"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| list="$(seq -s, 1 "$n")" | |
| echo "mode=subset -> $n shard(s)" | |
| { echo "total=$n"; echo "list=[$list]"; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| { echo "total=8"; echo "list=[1,2,3,4,5,6,7,8]"; } >> "$GITHUB_OUTPUT" | |
| # Auto-fix the PR branch: run every autofix we have — ESLint `--fix` then | |
| # Prettier `--write` — and commit the result back onto the head branch. This | |
| # is the "fixer" half of the format gate; `precheck` still runs `lint` + | |
| # `format:check` as the "gate" half, which stays honest when this job is off | |
| # (no PAT) or can't run (fork PR). | |
| # | |
| # Scope to files changed vs the PR base (not the whole tree): full-repo | |
| # `eslint . --fix` regularly OOM-kills (exit 137) on the 6 GB check runners | |
| # under fleet load, painting every PR red before precheck even starts — | |
| # `prettier --write .` did too, before oxfmt replaced it. Diff-scoped autofix | |
| # is enough — the gate half still checks the whole tree — and keeps peak | |
| # memory proportional to the PR. | |
| # | |
| # A SEPARATE job rather than folded into `precheck`: precheck checks out the | |
| # merge ref (fetch-depth: 0) so the e2e oracle can diff against base, whereas | |
| # committing needs the branch (head_ref) checkout. The formatter runs last so it tidies | |
| # whatever ESLint rewrote; `eslint-config-prettier` means the two never fight. | |
| # | |
| # Pushed as the copse-release-bot App rather than with the default | |
| # GITHUB_TOKEN: GitHub won't start a run for a GITHUB_TOKEN push (recursion | |
| # guard), so a token-pushed format commit would land as the PR head with no CI | |
| # and leave the PR blocked. An App push is a distinct actor and fires | |
| # `pull_request: synchronize` normally, and the top-level | |
| # `concurrency: cancel-in-progress` supersedes THIS run with one on the | |
| # formatted commit — so build/e2e aren't paid for twice. It terminates in one | |
| # extra run: the re-triggered run reformats nothing (idempotent), so this job | |
| # finds no diff and pushes nothing. Falls back to github.token when the App | |
| # credentials are unset (commit lands, just no CI re-trigger — the behaviour | |
| # before any push identity was configured). | |
| autoformat: | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | |
| # Hosted by default; SELF_HOSTED_CHECKS=copse-checks opts this job onto the | |
| # self-hosted check fleet (see the routing note above). Watch for 6 GB self-hosted OOM on eslint (see precheck). | |
| runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }} | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: write | |
| steps: | |
| # Dispatched by `pull_request` but often EXECUTED minutes later, queued | |
| # behind the fleet. A PR that merges in the meantime takes its head branch | |
| # with it (auto-delete on merge), so checking out by branch name fails with | |
| # "branch not found" and paints a red X on an already-merged PR. There is nothing to push | |
| # formatting fixes to once the branch is gone; detect that first and no-op | |
| # green. Skipping `changed` leaves `steps.changed.outputs.any` empty, which | |
| # already gates setup and every step after it. | |
| - name: Check head branch still exists | |
| id: head | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BRANCH: ${{ github.head_ref }} | |
| # Self-hosted check-fleet images ship curl+jq but not the gh CLI | |
| # (ci-runners/Dockerfile). Prefer the REST API over `gh api`. | |
| GITHUB_API_URL: ${{ github.api_url }} | |
| run: | | |
| ENC_BRANCH=$(printf '%s' "$BRANCH" | jq -sRr @uri) | |
| HTTP_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "${GITHUB_API_URL}/repos/${{ github.repository }}/branches/${ENC_BRANCH}") || { | |
| echo "::error::Could not query head branch '$BRANCH'; refusing to treat a transport failure as branch deletion." | |
| exit 1 | |
| } | |
| case "$HTTP_STATUS" in | |
| 200) echo "exists=true" >> "$GITHUB_OUTPUT" ;; | |
| 404) | |
| echo "::notice::Head branch '$BRANCH' no longer exists (PR merged and branch deleted) — nothing to autoformat." | |
| echo "exists=false" >> "$GITHUB_OUTPUT" | |
| ;; | |
| *) | |
| echo "::error::Head branch lookup returned HTTP $HTTP_STATUS; refusing to skip autoformat." | |
| exit 1 | |
| ;; | |
| esac | |
| # continue-on-error preserves that documented degradation: a missing or | |
| # broken App credential leaves `outputs.token` empty and the checkout | |
| # below falls through to github.token rather than reddening the PR. | |
| - uses: actions/create-github-app-token@v3 | |
| id: app-token | |
| if: steps.head.outputs.exists == 'true' | |
| continue-on-error: true | |
| with: | |
| app-id: ${{ secrets.RELEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} | |
| permission-contents: write | |
| - uses: actions/checkout@v7.0.1 | |
| if: steps.head.outputs.exists == 'true' | |
| with: | |
| ref: ${{ github.head_ref }} | |
| fetch-depth: 0 | |
| token: ${{ steps.app-token.outputs.token || github.token }} | |
| # Decide whether there is anything to fix BEFORE installing. `npm ci` / | |
| # cache-restore is ~2-5 min on this fleet and the autofix itself takes | |
| # seconds, so on a diff with no formattable file the old order paid the | |
| # whole install to print "nothing to do". Only git is needed to answer the | |
| # question, and checkout has already run. | |
| - id: changed | |
| name: List autofixable changed files | |
| if: steps.head.outputs.exists == 'true' | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then | |
| git fetch --no-tags --depth=1 origin "$BASE_SHA" | |
| fi | |
| # NUL-delimited via a temp file rather than a step output: a path is | |
| # allowed to contain anything but NUL, and a step output cannot carry a | |
| # newline safely. | |
| git diff -z --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD \ | |
| -- '*.cjs' '*.css' '*.cts' '*.js' '*.json' '*.jsx' '*.md' '*.mjs' \ | |
| '*.mts' '*.ts' '*.tsx' '*.yaml' '*.yml' \ | |
| > "${RUNNER_TEMP}/autofix-files.z" || true | |
| if [ -s "${RUNNER_TEMP}/autofix-files.z" ]; then | |
| echo "any=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "No autofixable files changed vs base — skipping install and autofix." | |
| echo "any=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - uses: ./.github/actions/setup | |
| if: steps.changed.outputs.any == 'true' | |
| # `|| true` on eslint: a non-autofixable lint error must not abort the run — | |
| # we still want the fixes ESLint did apply to land. Real lint failures stay | |
| # the job of precheck's `lint` gate, not this fixer. The formatter retries | |
| # once on OOM (137); a second kill fails the job so a wedged runner is | |
| # visible. oxfmt is a Rust binary and has not been seen to OOM here, but | |
| # the retry costs nothing and this fleet is where the kills happened. | |
| - name: Autofix changed files | |
| if: steps.changed.outputs.any == 'true' | |
| run: | | |
| mapfile -d '' -t files < "${RUNNER_TEMP}/autofix-files.z" | |
| echo "Autofixing ${#files[@]} changed file(s)" | |
| npx eslint --fix -- "${files[@]}" || true | |
| set +e | |
| npx oxfmt --write -- "${files[@]}" | |
| status=$? | |
| set -e | |
| if [ "$status" -eq 0 ]; then exit 0; fi | |
| if [ "$status" -ne 137 ]; then exit "$status"; fi | |
| echo "::warning::oxfmt OOM-killed (137); retrying once" | |
| sleep 10 | |
| npx oxfmt --write -- "${files[@]}" | |
| - name: Commit and push fixes | |
| if: steps.changed.outputs.any == 'true' | |
| run: | | |
| if git diff --quiet; then | |
| echo "No formatting changes to commit." | |
| exit 0 | |
| fi | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git commit -am "style: apply eslint --fix and oxfmt" | |
| git push | |
| check: | |
| needs: precheck | |
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | |
| # Hosted by default; SELF_HOSTED_CHECKS=copse-checks opts this job onto the | |
| # self-hosted check fleet (see the routing note above). History: coverage/esbuild has died with "service was stopped" on | |
| # the 6 GB self-hosted fleet under load (same class as precheck OOM) — add | |
| # headroom if it recurs. | |
| runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }} | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: ./.github/actions/setup | |
| # Preparation tests exercise the real kernel boundary and must not skip it. | |
| - name: Install sandbox dependencies for unit integration tests | |
| if: runner.environment == 'github-hosted' && runner.os == 'Linux' | |
| run: | | |
| set -euxo pipefail | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends bubblewrap socat | |
| if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then | |
| sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 | |
| fi | |
| bwrap --unshare-all --dev-bind / / --die-with-parent true | |
| # Unit suite under c8 + coverage-baseline.json ratchet (replaces the | |
| # separate coverage job — same tests, no duplicate npm test run). | |
| # | |
| # THINNING (stacked PRs only). A PR whose base is another PR's branch | |
| # cannot merge yet: the layer below it has to land first, and when it does | |
| # this PR is retargeted at trunk and runs this job again under the `full` | |
| # arm. So the run that actually gates a merge into `main`/`release` is | |
| # always the whole suite plus the coverage ratchet — thinning never buys a | |
| # green that lets unrun tests reach trunk. That is the same "lowest | |
| # unmerged PR gates the merge" rule the heavy tier already applies via | |
| # `base_ref`, extended to the one tier that had no diff-awareness at all. | |
| # | |
| # Only `subset` skips the ratchet, and it must: a partial run produces a | |
| # coverage number that is not comparable to the baseline, so gating on it | |
| # would fail every thinned run. `skip` (docs-only) drops both. | |
| # An unset `unit_mode` is read as `full`, so a plan branch that forgets to | |
| # emit one fails safe. | |
| - name: Unit tests | |
| env: | |
| # c8 retains V8 coverage until the suite exits. Test entries share | |
| # split ESM chunks, so remapping scales with unique application code | |
| # instead of hundreds of dependency-inlined standalone bundles. | |
| # | |
| # The peak is c8's post-run remap in a single process, after every | |
| # test worker has already exited -- not the workers themselves, so | |
| # TEST_FILE_CONCURRENCY does not bound it. 3072 aborted there with | |
| # "Ineffective mark-compacts near heap limit" once the whole suite had | |
| # passed; 6144 completes the remap and the ratchet. ubuntu-latest has | |
| # 16 GB, and only this one process is live at that point. | |
| NODE_OPTIONS: --max-old-space-size=6144 | |
| UNIT_MODE: ${{ needs.precheck.outputs.unit_mode }} | |
| UNIT_SPECS: ${{ needs.precheck.outputs.unit_specs }} | |
| # True only for a PR stacked on another PR's branch. `push`, tags and | |
| # the nightly schedule have no base_ref and are never thinnable. | |
| STACKED_PR: ${{ github.event_name == 'pull_request' && github.base_ref != 'main' && github.base_ref != 'release' }} | |
| # Through the environment, never `${{ }}` inside the script: a base | |
| # branch name is attacker-chosen text on any PR, and interpolating it | |
| # into `run:` would splice it into this shell. | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$UNIT_MODE" = "skip" ]; then | |
| echo "Oracle: docs-only diff — no unit test can observe it. Skipping the unit suite." | |
| exit 0 | |
| fi | |
| if [ "$STACKED_PR" = "true" ] && [ "$UNIT_MODE" = "subset" ] && [ -n "$UNIT_SPECS" ]; then | |
| n=0 | |
| for _ in $UNIT_SPECS; do n=$(( n + 1 )); done | |
| echo "Stacked PR (base '${BASE_REF}' is not trunk) -> $n selected unit file(s)." | |
| echo "The trunk-targeted run of this PR will run the full suite + coverage ratchet." | |
| # Deliberately unquoted: UNIT_SPECS is a space-separated file list. | |
| # shellcheck disable=SC2086 | |
| npm test -- $UNIT_SPECS | |
| exit 0 | |
| fi | |
| npm run coverage:ci | |
| # The console gets the `dot` reporter (see scripts/run-tests.mts) so a | |
| # failure is reachable through the job-log API's ~5000-line tail. This is | |
| # the full TAP for the same run, for when you want the passing detail too. | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: unit-tests-tap | |
| path: unit-tests.tap | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| # Diagnostic HTML only — coverage:ci / coverage-gate already decided pass/fail. | |
| # Org artifact-storage quota must not fail the merge gate after a green suite | |
| # (seen on #1074: CreateArtifact quota error after 0 unit failures). | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: coverage-report | |
| path: coverage/lcov-report | |
| if-no-files-found: ignore | |
| # Debug/inspection aid, not a durable record — expire quickly so it | |
| # doesn't accumulate against the org's Actions storage quota. | |
| retention-days: 7 | |
| # The CommonMark normalizer-parity check now lives in the extracted | |
| # @copse/streaming-markdown repo's CI (copse-dev/streaming-markdown). | |
| # The build is validated once in the dedicated `build` job below (whose | |
| # dist artifact the e2e shards reuse), so it no longer runs here. | |
| # Retrieval-quality gate for the semantic-search backend. This job indexes the | |
| # repo and runs the fixture query set, asserting recall@k and search latency | |
| # stay within the committed thresholds. A drop in ranking quality (or a gortex | |
| # CLI/JSON change that breaks parsing) fails here rather than silently | |
| # degrading in-app search. | |
| bench: | |
| needs: precheck | |
| if: >- | |
| (github.event_name != 'push' || github.ref != 'refs/heads/main') && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) && | |
| (github.event_name != 'pull_request' || | |
| github.base_ref == 'release' || | |
| contains(github.event.pull_request.labels.*.name, 'ci-full')) && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.draft == false || | |
| contains(github.event.pull_request.labels.*.name, 'ci-full')) | |
| # HEAVY TIER. Runs on promotion PRs (base `release`), on pushes to `release`, | |
| # on the nightly schedule and on release tags — not on the day-to-day PRs into | |
| # `main`, and not on pushes to `main`. bench fetches gortex and runs | |
| # the semantic-search + agent gates; paying that per PR when ~20 land a day | |
| # is the cost the trunk/promotion model exists to amortise. The `ci-full` | |
| # label forces it on a trunk-targeted PR that genuinely wants the signal. | |
| # Draft PRs skip the heavy tier. Drafts cannot merge, so the expensive | |
| # signal has no consumer until the PR is promoted — and `ready_for_review` | |
| # (see the trigger block) dispatches the full tier the moment it is. The | |
| # `ci-full` label forces it earlier for a draft that genuinely wants it. | |
| # `precheck` / `check` / `autoformat` still run on drafts, so lint, types, | |
| # unit tests and the autofixer keep giving fast feedback. | |
| # Hosted by default; SELF_HOSTED_CHECKS=copse-checks opts this job onto the | |
| # self-hosted check fleet (see the routing note above). No Electron/display need. | |
| runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }} | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: ./.github/actions/setup | |
| # Guarantee the gortex binary independently of the setup action. On a | |
| # self-hosted runner whose baked image predates gortex, the baked-deps seed | |
| # sets seeded=true (so npm ci + its postinstall fetch are skipped) yet the | |
| # baked layer has no vendor/gortex to copy — leaving the binary absent. | |
| # fetch-gortex is idempotent: a no-op when the (checksum-verified) binary is | |
| # already present, a download when it isn't. | |
| - run: node scripts/fetch-gortex.mts | |
| # The floor is a breakage detector, not a target: a gortex CLI/JSON change | |
| # that breaks parsing craters recall to ~0, and that's the main thing this | |
| # catches. Recall is deterministic per corpus (~81% observed on 16 fixture | |
| # queries), but unrelated PRs that add files shift rankings by a query or | |
| # two, so the 0.65 floor leaves headroom to avoid flaking those builds. The | |
| # p95 ceiling is generous vs the ~130ms observed locally (CI is slower, the | |
| # daemon starts cold). Tighten either once the fixture set is larger. | |
| - run: >- | |
| node scripts/semantic-search-bench.mts --backend gortex --gate | |
| --min-recall 0.65 --max-p95-ms 3000 --json bench-semantic.json | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: bench-semantic | |
| path: bench-semantic.json | |
| if-no-files-found: ignore | |
| # Small trend JSON; keep two weeks for regression triage, then expire. | |
| retention-days: 14 | |
| # Agent bench harness self-test (#752): the deterministic mock run drives | |
| # run-agent-loop headlessly end-to-end (directive tool call → workspace | |
| # write → grading) and the --gate ratchet compares solve rate and | |
| # tokens-per-solve against benchmarks/bench-baseline.json. No model, ~30s. | |
| - run: npm run bench:agent -- --mock --gate | |
| # Doctrine ablation harness self-test (#744): runs the full and omit-tools | |
| # arms through the real agent loop with a deterministic mock task. The | |
| # real-model matrix stays in doctrine-eval-model below. | |
| - run: >- | |
| npm run eval:doctrine -- --provider mock --repeats 1 --sections tools | |
| --out bench-results/doctrine-mock --require-solved --require-doctrine | |
| # Steer A/B harness self-test: drives both arms of the mock pack through | |
| # the real agent loop, including git workspace setup and the check runner. | |
| # The mock ignores the system prompt, so this proves the harness works and | |
| # says nothing about any steer — real-model lift is an on-demand run | |
| # (docs/steer-evals.md), never a per-PR gate. | |
| - run: >- | |
| npm run eval:steer -- --provider mock --repeats 1 | |
| --out bench-results/steer-mock --require-gates | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: bench-agent-mock | |
| path: bench-results/ | |
| if-no-files-found: ignore | |
| # Small trend data; keep two weeks for regression triage, then expire. | |
| retention-days: 14 | |
| # Real-model agent benchmark (#752): the SWE-bench Verified pinned subset | |
| # driven by a local model on a self-hosted runner with an LM Studio host. | |
| # Runs nightly (schedule) or on demand via the `bench-agent` PR label, and | |
| # only when the LM_EVAL_RUNNER repo variable names a runner. Deliberately | |
| # OUT of the ci-passed critical path (docs/testing-strategy.md): slow, | |
| # non-deterministic trend data, not a merge gate. | |
| bench-agent-model: | |
| needs: precheck | |
| if: >- | |
| vars.LM_EVAL_RUNNER != '' && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) && | |
| (github.event_name == 'schedule' || | |
| contains(github.event.pull_request.labels.*.name, 'bench-agent')) | |
| runs-on: ${{ vars.LM_EVAL_RUNNER }} | |
| timeout-minutes: 90 | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: ./.github/actions/setup | |
| # Resolve the pinned instance ids into task files (network required). | |
| - run: npm run bench:swe-tasks | |
| - env: | |
| LM_STUDIO_URL: ${{ vars.LM_STUDIO_URL }} | |
| LM_STUDIO_MODEL: ${{ vars.LM_STUDIO_MODEL }} | |
| LM_STUDIO_API_KEY: ${{ secrets.LM_STUDIO_API_KEY }} | |
| run: npm run bench:agent -- --tasks benchmarks/tasks/swe-bench --gate | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: bench-agent-model | |
| path: bench-results/ | |
| if-no-files-found: ignore | |
| # Small trend data; keep two weeks for regression triage, then expire. | |
| retention-days: 14 | |
| # Working-style doctrine prompt-section A/B (#744): same fixed task subset, | |
| # model, and sampling posture for full vs omitted prompt sections. Runs only | |
| # nightly or when a PR is explicitly labelled `bench-doctrine`, and only on a | |
| # configured LM Studio eval runner. Trend report only; never a merge gate. | |
| doctrine-eval-model: | |
| needs: precheck | |
| if: >- | |
| vars.LM_EVAL_RUNNER != '' && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) && | |
| (github.event_name == 'schedule' || | |
| contains(github.event.pull_request.labels.*.name, 'bench-doctrine')) | |
| runs-on: ${{ vars.LM_EVAL_RUNNER }} | |
| timeout-minutes: 90 | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: ./.github/actions/setup | |
| - env: | |
| LM_STUDIO_URL: ${{ vars.LM_STUDIO_URL }} | |
| LM_STUDIO_MODEL: ${{ vars.LM_STUDIO_MODEL }} | |
| LM_STUDIO_API_KEY: ${{ secrets.LM_STUDIO_API_KEY }} | |
| run: >- | |
| npm run eval:doctrine -- --provider lmstudio --repeats 3 | |
| --sections tools,workingStyle,gitBranchSafety | |
| --out bench-results/doctrine | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: doctrine-eval-model | |
| path: bench-results/doctrine/ | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| # Tool-preference eval (#1845): asked a read-only "what landed on main, is CI | |
| # green?" question, does the agent answer through the first-class git/gh/CI | |
| # tools, or does it drive `gh` and network `git` through run_shell and charge | |
| # the user an external-shell approval for each? The exported thread in that | |
| # issue took five. A GA blocker, but deliberately NOT a merge gate: the | |
| # scoring it rests on is unit-tested on every PR | |
| # (scripts/lib/eval-scenario-git-ci-tools.test.ts, which replays that thread), | |
| # while this lane measures what real models actually do. Nightly, or on demand | |
| # via the `eval-tool-preference` PR label, and only on a configured eval | |
| # runner. | |
| # | |
| # The matrix is what the issue's acceptance criteria ask for: the native loop | |
| # across the prompt class's wordings, plus one ACP path, whose namespaced | |
| # bridged calls the scorer matches through `matchesBridgedToolName`. | |
| # `fail-fast` is off because which wording an agent trips on IS the finding — | |
| # stopping the matrix at the first red would throw that away. | |
| eval-tool-preference: | |
| needs: precheck | |
| if: >- | |
| vars.LM_EVAL_RUNNER != '' && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) && | |
| (github.event_name == 'schedule' || | |
| contains(github.event.pull_request.labels.*.name, 'eval-tool-preference')) | |
| runs-on: ${{ vars.LM_EVAL_RUNNER }} | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - { path: native, model: '', variant: '0' } | |
| - { path: native, model: '', variant: '1' } | |
| - { path: native, model: '', variant: '2' } | |
| # The ACP arm runs the wording taken verbatim from the exported | |
| # thread, so a regression there is comparable to the recorded case. | |
| - { path: acp-codex, model: 'acp:codex-acp', variant: '0' } | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: ./.github/actions/setup | |
| # The eval drives the packaged renderer through Electron, not the dev | |
| # server, so the bundle has to exist before WDIO launches. | |
| - run: npm run build | |
| - env: | |
| COPSE_EVAL_SCENARIO: tests/e2e/scenarios/git-ci-first-class-tools.json | |
| COPSE_EVAL_PROMPT_VARIANT: ${{ matrix.variant }} | |
| COPSE_EVAL_MODEL: ${{ matrix.model }} | |
| LM_STUDIO_URL: ${{ vars.LM_STUDIO_URL }} | |
| LM_STUDIO_MODEL: ${{ vars.LM_STUDIO_MODEL }} | |
| LM_STUDIO_API_KEY: ${{ secrets.LM_STUDIO_API_KEY }} | |
| run: npm run test:e2e:agent-eval | |
| # Publish the pass/fail artifact even when the step above already failed | |
| # its in-run asserts: a run that violated the expectations is exactly the | |
| # one whose scored trace is worth reading. Same scoring a human gets from | |
| # `npm run analyze:thread` on the captured JSONL, so the lane and a local | |
| # investigation cannot disagree. | |
| - if: always() | |
| shell: bash | |
| run: | | |
| artifact=$(ls -t tests/e2e/artifacts/git-ci-first-class-tools-*.jsonl 2>/dev/null | head -1) | |
| if [ -z "$artifact" ]; then | |
| echo "no eval artifact captured — the run died before writing a thread" | |
| exit 1 | |
| fi | |
| report="tests/e2e/artifacts/report-${{ matrix.path }}-${{ matrix.variant }}.json" | |
| npm run --silent analyze:thread -- "$artifact" \ | |
| tests/e2e/scenarios/git-ci-first-class-tools.json > "$report" | |
| cat "$report" | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: eval-tool-preference-${{ matrix.path }}-${{ matrix.variant }} | |
| path: tests/e2e/artifacts/ | |
| if-no-files-found: ignore | |
| # Trend + triage data for a GA gate; a month covers a release cycle. | |
| retention-days: 30 | |
| # Scratch-path GA bar (#1846): a sandboxed agent asked to stage intermediate | |
| # output somewhere temporary must use the $TMPDIR the sandbox hands it (or the | |
| # workspace), never a hardcoded /tmp/... — which the scope heuristic classes as | |
| # external and charges the user an approval for. Deterministic scoring lives in | |
| # `npm test` (eval-scratch-paths / eval-tool-expectations); this job is the | |
| # behavioural half, so it needs a real model and the real Electron app. | |
| # | |
| # Runs nightly or on the `bench-agent-eval` label, on the same LM Studio eval | |
| # runner as doctrine-eval-model. Every prompt variant runs even after one | |
| # fails: the wording is meant to vary while the intent stays fixed, so one red | |
| # phrasing out of three is a different finding from three reds, and fail-fast | |
| # would report them as the same thing. | |
| scratch-path-eval-model: | |
| needs: precheck | |
| if: >- | |
| vars.LM_EVAL_RUNNER != '' && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) && | |
| (github.event_name == 'schedule' || | |
| contains(github.event.pull_request.labels.*.name, 'bench-agent-eval')) | |
| runs-on: ${{ vars.LM_EVAL_RUNNER }} | |
| timeout-minutes: 120 | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: ./.github/actions/setup | |
| - run: npm run build | |
| # The ACP arm only runs where an adapter is actually installed, so the | |
| # agent is named by a repo variable (e.g. `acp:codex-acp`) rather than | |
| # assumed. Unset, the native arm still gates on its own. | |
| - name: Agent scratch-path eval | |
| env: | |
| COPSE_EVAL_SCENARIO: tests/e2e/scenarios/tmpdir-scratch-eval.json | |
| COPSE_ACP_EVAL_MODEL: ${{ vars.ACP_EVAL_MODEL }} | |
| LM_STUDIO_URL: ${{ vars.LM_STUDIO_URL }} | |
| COPSE_EVAL_LOCAL_SERVER_URL: ${{ vars.LM_STUDIO_URL }} | |
| LM_STUDIO_API_KEY: ${{ secrets.LM_STUDIO_API_KEY }} | |
| run: | | |
| set -uo pipefail | |
| failed=0 | |
| for arm in native acp; do | |
| if [ "$arm" = acp ]; then | |
| [ -n "${COPSE_ACP_EVAL_MODEL:-}" ] || { echo "::notice::ACP_EVAL_MODEL unset, skipping the ACP arm"; continue; } | |
| export COPSE_EVAL_MODEL="$COPSE_ACP_EVAL_MODEL" | |
| else | |
| unset COPSE_EVAL_MODEL | |
| fi | |
| for variant in 0 1 2; do | |
| echo "::group::$arm prompt variant $variant" | |
| COPSE_EVAL_PROMPT_VARIANT="$variant" npm run test:e2e:agent-eval || failed=1 | |
| echo "::endgroup::" | |
| done | |
| done | |
| exit "$failed" | |
| - uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: scratch-path-eval-model | |
| # The JSONL thread export per run. A failure message names the | |
| # offending command; the trace is how you see what led to it. | |
| path: tests/e2e/artifacts/ | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| build: | |
| needs: precheck | |
| if: >- | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.draft == false || | |
| contains(github.event.pull_request.labels.*.name, 'ci-full')) | |
| # Draft PRs skip the heavy tier. Drafts cannot merge, so the expensive | |
| # signal has no consumer until the PR is promoted — and `ready_for_review` | |
| # (see the trigger block) dispatches the full tier the moment it is. The | |
| # `ci-full` label forces it earlier for a draft that genuinely wants it. | |
| # `precheck` / `check` / `autoformat` still run on drafts, so lint, types, | |
| # unit tests and the autofixer keep giving fast feedback. | |
| # Hosted by default; SELF_HOSTED_CHECKS=copse-checks opts this job onto the | |
| # self-hosted check fleet (see the routing note above). Produces the dist artifact consumed by the e2e shards below. | |
| runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }} | |
| timeout-minutes: 30 | |
| # Build dist once and hand it to every e2e shard. Previously each shard ran | |
| # its own `npm run build`; an earlier attempt to share dist via upload/ | |
| # download dropped some Monaco worker files, breaking specs that open the | |
| # diff editor. Packing dist into a single tar before upload preserves the | |
| # full tree (worker files, nested dirs, exec bits) verbatim, so the shards | |
| # get a byte-identical dist without each rebuilding it. | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: ./.github/actions/setup | |
| - run: npm run build | |
| # Plain-Chrome geometry coverage replaces Electron specs that do not need | |
| # main/preload IPC. Skip screenshot-only heads: their parent already ran | |
| # this tier and attached its immutable render evidence. | |
| - run: npm run build:demo | |
| if: needs.precheck.outputs.mode != 'skip' | |
| - run: npm run test:demo | |
| if: needs.precheck.outputs.mode != 'skip' | |
| - name: Collect changed browser reference screenshots | |
| if: >- | |
| success() && github.event_name == 'pull_request' && | |
| needs.precheck.outputs.mode != 'skip' | |
| run: | | |
| mkdir -p changed/tests/e2e/screenshots | |
| touch changed/.demo | |
| mapfile -t files < <( | |
| { | |
| git diff --name-only -- tests/e2e/screenshots/ | |
| git ls-files --others --exclude-standard -- tests/e2e/screenshots/ | |
| } | sort -u | |
| ) | |
| for f in "${files[@]}"; do | |
| [ -n "$f" ] || continue | |
| cp "$f" "changed/tests/e2e/screenshots/" | |
| done | |
| echo "Collected ${#files[@]} browser screenshot(s)" | |
| - uses: actions/upload-artifact@v7 | |
| if: >- | |
| success() && github.event_name == 'pull_request' && | |
| needs.precheck.outputs.mode != 'skip' | |
| with: | |
| name: screenshots-demo | |
| path: changed/ | |
| include-hidden-files: true | |
| if-no-files-found: error | |
| - uses: actions/upload-artifact@v7 | |
| if: failure() | |
| with: | |
| name: demo-failure-artifacts | |
| path: e2e-failure-artifacts/ | |
| if-no-files-found: ignore | |
| retention-days: 3 | |
| - run: tar -cf dist.tar dist | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: dist-tar | |
| path: dist.tar | |
| if-no-files-found: error | |
| retention-days: 1 | |
| e2e: | |
| needs: [build, precheck] | |
| # The oracle decides the cost, not whether uncertain changes receive a gate: | |
| # `subset` runs only the specs it can map confidently; `full` means the map is | |
| # broad or LOW-confidence, so every shard runs. Treating `full` as a reason to | |
| # skip e2e inverted the risk policy — the least-understood changes could merge | |
| # with less integration coverage than confidently mapped UI changes. | |
| # | |
| # Draft PRs are unaffected: the `draft == false` clause below still applies, | |
| # so neither a subset nor a full plan dispatches shards on work in progress. | |
| if: >- | |
| needs.precheck.outputs.e2e_shard_total != '0' && | |
| (github.event_name != 'push' || github.ref != 'refs/heads/main') && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) && | |
| (github.event_name != 'pull_request' || | |
| github.base_ref == 'release' || | |
| needs.precheck.outputs.mode == 'full' || | |
| needs.precheck.outputs.mode == 'subset' || | |
| contains(github.event.pull_request.labels.*.name, 'ci-full') || | |
| contains(github.event.pull_request.labels.*.name, 'update-screenshots')) && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.draft == false || | |
| contains(github.event.pull_request.labels.*.name, 'ci-full')) | |
| # Zero-shard plans (oracle mode=skip / empty subset) skip this job via | |
| # `e2e_shard_total != 0` — an empty matrix is a GHA **failure**, not a skip | |
| # (see the precheck shards step). | |
| # Full mode is intentionally expensive: it is the fail-safe result for a | |
| # change the selector map cannot bound. Confident subsets keep ordinary UI | |
| # changes cheap; docs-only changes produce zero shards and skip this job. | |
| # `ci-full` remains the explicit override for a draft or a change whose | |
| # author wants exhaustive coverage despite a subset plan. | |
| # | |
| # `update-screenshots` also runs this job on a trunk PR: screenshot | |
| # candidates are produced by the e2e run, so without it the label would be | |
| # inert on a plan that plans no shards. | |
| # Draft PRs skip e2e for the same reason as `build` above; `build` is also | |
| # gated, so on a draft this job's `needs` are unsatisfied and it would skip | |
| # regardless — the explicit clause keeps the intent readable at this job. | |
| # e2e runs on GitHub-hosted ubuntu-latest by default, for every event. | |
| # The self-hosted Docker fleet (see ci-runners/) is opt-in via | |
| # SELF_HOSTED_E2E=copse-e2e. The fleet was the default while this repo was | |
| # private, when hosted meant 2-core/7GB — the box most of the shard | |
| # quarantines exist to work around. A public repo gets 4-vCPU / 16 GiB | |
| # hosted runners for free, which is more headroom than the 6 GB `copse-e2e` | |
| # container, so the default no longer trades capacity for cost. | |
| # | |
| # Inverting this also removes a wedge: the old expression treated any | |
| # SELF_HOSTED_E2E value other than the exact string 'ubuntu-latest' — | |
| # including UNSET — as "use the fleet". With no registered `copse-e2e` | |
| # runner, deleting one variable queued every PR/push e2e job indefinitely. | |
| # Now the unset case is hosted, so the failure mode of a missing or | |
| # mistyped variable is "runs on hosted", not "never runs". | |
| # | |
| # Runner is still picked by TRUST and still fails closed: | |
| # - Nightly `schedule` and trusted push/same-repo PRs default to hosted. | |
| # - The opt-in label set requires BOTH `self-hosted` AND `copse-e2e`, so an | |
| # opted-in job lands only on the Linux Docker runners, never the macOS | |
| # `self-hosted` boxes (which lack `copse-e2e`). | |
| # - Fork PRs are skipped by the `if` guard above and must never run on any | |
| # runner. There is deliberately no fork branch here: if that guard were | |
| # ever removed, this expression evaluates to a falsy value (not a runner | |
| # label) so the job fails closed instead of dispatching untrusted code | |
| # anywhere — including onto a free hosted runner. | |
| runs-on: >- | |
| ${{ github.event_name == 'schedule' && fromJSON('["ubuntu-latest"]') || | |
| ((github.event_name == 'push' || | |
| (github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.full_name == github.repository)) | |
| && (vars.SELF_HOSTED_E2E == 'copse-e2e' | |
| && fromJSON('["self-hosted", "copse-e2e"]') | |
| || fromJSON('["ubuntu-latest"]'))) }} | |
| timeout-minutes: 45 | |
| # Split the seeded e2e suite (see wdio.ci.conf.ts) into parallel shards so the | |
| # whole gate finishes well inside the runner window. Each shard runs a disjoint | |
| # slice of specs via wdio's `--shard current/total`. The shared `dist` artifact | |
| # from the `build` job is unpacked here rather than rebuilt per shard. | |
| # | |
| # Every full run uses 8 shards. Each shard runs its slice as one sequential | |
| # wdio process, relaunching Electron per spec. Both the hosted runner (7 GB) | |
| # and self-hosted container (6 GB cap) eventually exhaust memory when a shard | |
| # launches too many Electron/gortex processes; a later unrelated spec then | |
| # fails to boot or OOM-crashes ("shutdown signal"). #339's data showed that | |
| # 8 shards stays under that limit even in the densest spec range. | |
| # The per-attempt `timeout` is 480s (step cap 18 min for 2 attempts) so attempt | |
| # 1 finishes instead of being SIGKILL'd mid-retry, letting attempt 2 relaunch. | |
| strategy: | |
| # Fail fast: the first shard to go red cancels the rest. On the self-hosted | |
| # fleet a failure is usually a whole-run problem (a poisoned dep cache, a | |
| # broken commit, a runner-env issue) that every shard would hit anyway, so | |
| # cancelling the siblings frees the box instead of grinding them all through | |
| # their retry budget. Tradeoff: you see the first failing shard, not every | |
| # failing shard, in one run. | |
| # | |
| # `ci-full` is the exception, because there the tradeoff inverts. That label | |
| # already means "run the whole heavy tier on a trunk PR" — it is opt-in and | |
| # rare, and it is used precisely when the e2e signal is what's wanted. With | |
| # independent reds (not a whole-run problem) fail-fast surfaces exactly one | |
| # per run: #1425 spent five runs learning about six specs, cancelling six | |
| # shards each time, because each red hid the next. Letting a `ci-full` run | |
| # report every shard costs fleet time on a PR that is already failing, and | |
| # buys the complete list in one pass. | |
| # | |
| # Non-PR events have no labels, so `contains()` is false and fail-fast stays | |
| # on for pushes, the nightly schedule, and tags — the fleet-protecting | |
| # default is unchanged everywhere it was doing its job. | |
| fail-fast: ${{ !contains(github.event.pull_request.labels.*.name, 'ci-full') }} | |
| matrix: | |
| shard: ${{ fromJSON(needs.precheck.outputs.e2e_shards) }} | |
| env: | |
| SHARD_TOTAL: ${{ needs.precheck.outputs.e2e_shard_total }} | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: ./.github/actions/setup | |
| # Reclaim ~20-30 GB of runner-preloaded toolchains (dotnet/android/ghc/ | |
| # CodeQL/swift) and cached Docker images. The e2e shards have hit "No space | |
| # left on device" mid-run, which cascades into OOM / runner-shutdown and | |
| # Electron session-startup failures; freeing space up front removes that | |
| # whole failure class. Best-effort — never fail the job on cleanup. Only on | |
| # GitHub-hosted runners (the nightly) — never rm system dirs on a | |
| # self-hosted box. | |
| - name: Free disk space | |
| if: runner.environment == 'github-hosted' | |
| run: | | |
| sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ | |
| /opt/hostedtoolcache/CodeQL /usr/share/swift /usr/local/share/boost || true | |
| sudo docker image prune --all --force >/dev/null 2>&1 || true | |
| df -h / | |
| # Outer retry: actions/download-artifact marks intermittent CDN 403s | |
| # ("Error from intermediary") as non-retryable, and with fail-fast:true a | |
| # single shard's download flake cancels the whole e2e matrix (see | |
| # actions/download-artifact#464 and main run 29591165512). | |
| # GitHub-hosted images do not ship ASRT's Linux backend, and hosted is now | |
| # the default for every e2e event (nightly `schedule` included) — the | |
| # fleet is opt-in via SELF_HOSTED_E2E. Executable plugins fail closed | |
| # without bubblewrap+socat (selected-plugin-browser on tip 528f92ce / | |
| # runs 31781384161 and 31870672634), so the hosted-only step below | |
| # installs them. Self-hosted runners bake both into the image already | |
| # (ci-runners/Dockerfile), which is why it stays gated on | |
| # `runner.environment`. | |
| - name: Install Linux sandbox dependencies | |
| if: runner.environment == 'github-hosted' | |
| run: | | |
| set -euxo pipefail | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends bubblewrap socat | |
| # Ubuntu 24.04's AppArmor userns restriction lets bwrap create a | |
| # namespace but strips loopback setup (Failed RTM_NEWADDR). The | |
| # self-hosted host bootstrap clears the same sysctl; do that here | |
| # when the hosted runner permits it so ASRT can actually start. | |
| if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then | |
| sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 | |
| fi | |
| command -v bwrap | |
| command -v socat | |
| bwrap --unshare-all --dev-bind / / --die-with-parent true | |
| # The install above is hosted-only because the self-hosted image bakes | |
| # both in (ci-runners/Dockerfile). When a self-hosted runner comes up on a | |
| # stale image that assumption breaks silently — nothing installs, nothing | |
| # checks, and the first sign is a spec failing ~40 minutes later with a | |
| # product-shaped message: `selected-plugin-browser` reporting "Executable | |
| # plugin behavior requires Copse's active OS sandbox", or | |
| # `capability-lease-approval` waiting on a sandboxed turn-tree that never | |
| # renders. Both read as regressions in whichever PR drew the runner, which | |
| # is the expensive part. Run the same three checks the install step | |
| # already ends with, on every runner, and fail here — where the message | |
| # names the cause. | |
| - name: Verify sandbox preconditions | |
| if: >- | |
| needs.precheck.outputs.mode == 'full' || | |
| needs.precheck.outputs.mode == 'subset' | |
| run: | | |
| set -uo pipefail | |
| missing='' | |
| command -v bwrap >/dev/null 2>&1 || missing="${missing} bubblewrap(bwrap)" | |
| command -v socat >/dev/null 2>&1 || missing="${missing} socat" | |
| if [ -n "${missing}" ]; then | |
| probe='skipped (tools missing)' | |
| elif bwrap --unshare-all --dev-bind / / --die-with-parent true >/dev/null 2>&1; then | |
| probe='ok' | |
| else | |
| probe='FAILED' | |
| fi | |
| if [ -n "${missing}" ] || [ "${probe}" = 'FAILED' ]; then | |
| echo "runner=${RUNNER_NAME:-unknown} environment=${RUNNER_ENVIRONMENT:-unknown}" | |
| echo "missing:${missing:- none}" | |
| echo "bwrap probe: ${probe}" | |
| echo "::error title=e2e runner is not provisioned for the OS sandbox::missing:${missing:- none}; bwrap probe=${probe}. Specs that need the sandbox fail closed with product-shaped messages, so this fails here instead. Self-hosted runners get these from ci-runners/Dockerfile — this host is most likely on a stale image." | |
| exit 1 | |
| fi | |
| echo "sandbox preconditions ok (bwrap + socat present, probe passed)" | |
| - uses: ./.github/actions/download-artifact-retry | |
| with: | |
| name: dist-tar | |
| - run: tar -xf dist.tar | |
| # The Electron/Chromedriver e2e session occasionally fails to start on the | |
| # runner ("DevToolsActivePort file doesn't exist" / "POST /session" timeout). | |
| # The hang wedges the whole wdio run, so in-process retries don't recover — | |
| # only a fresh xvfb+Electron launch does. Retry the entire shard, cleaning | |
| # orphaned session processes before each attempt and when the step exits. | |
| # The runners are one job per PID-isolated container, so this cannot touch | |
| # another job. Wrap each attempt in `timeout` so a plain hang cannot stall | |
| # the job indefinitely. | |
| # Oracle gate (see the precheck job's plan step): `full` shards the whole | |
| # suite; `subset` distributes only the planned specs round-robin across the | |
| # same 8 shards (so no shard exceeds the per-shard memory budget the count | |
| # is tuned for); `skip` runs nothing. Every shard still reports a status, so | |
| # this composes with any required check without depending on an aggregate | |
| # gate first. | |
| - name: e2e shard ${{ matrix.shard }}/${{ env.SHARD_TOTAL }} (oracle-gated, retry on flaky session startup) | |
| if: >- | |
| needs.precheck.outputs.mode == 'full' || | |
| needs.precheck.outputs.mode == 'subset' | |
| timeout-minutes: 18 | |
| env: | |
| PLAN_MODE: ${{ needs.precheck.outputs.mode }} | |
| PLAN_SPECS: ${{ needs.precheck.outputs.specs }} | |
| run: | | |
| if [ "$PLAN_MODE" = "subset" ]; then | |
| # Round-robin the planned specs onto this shard; an empty slice means | |
| # this shard has nothing to do (subset smaller than 8) — report green. | |
| i=0 | |
| SPEC_ARGS="" | |
| for s in $PLAN_SPECS; do | |
| if [ $(( i % SHARD_TOTAL )) -eq $(( ${{ matrix.shard }} - 1 )) ]; then | |
| SPEC_ARGS="$SPEC_ARGS --spec $s" | |
| fi | |
| i=$(( i + 1 )) | |
| done | |
| if [ -z "$SPEC_ARGS" ]; then | |
| echo "shard ${{ matrix.shard }}: no specs in this subset slice — nothing to run" | |
| exit 0 | |
| fi | |
| SHARD_ARGS="" | |
| echo "oracle subset shard ${{ matrix.shard }}:${SPEC_ARGS}" | |
| else | |
| SHARD_ARGS="--shard ${{ matrix.shard }}/${{ env.SHARD_TOTAL }}" | |
| SPEC_ARGS="" | |
| fi | |
| # `timeout` is GNU coreutils — present on Linux, but on macOS it's | |
| # `gtimeout` (from `brew install coreutils`). Pick whichever exists; | |
| # fall back to no inner timeout (the step `timeout-minutes` still caps | |
| # a wedged run) so the command can't die with "timeout: not found". | |
| TIMEOUT="$(command -v timeout || command -v gtimeout || true)" | |
| # ASRT's Linux network bridge, matched by the socket path it owns. | |
| # | |
| # `@anthropic-ai/sandbox-runtime` spawns socat on both sides of the | |
| # sandbox boundary, and both forms name the same generated socket | |
| # (`linux-sandbox-utils.js`, initializeLinuxNetworkBridge): | |
| # | |
| # host: socat UNIX-LISTEN:$TMPDIR/claude-http-<hex>.sock,… TCP:localhost:<port>,… | |
| # sandbox: socat TCP-LISTEN:3128,… UNIX-CONNECT:$TMPDIR/claude-http-<hex>.sock | |
| # | |
| # Matching that socket name — rather than the bare word `socat` — is | |
| # what makes this safe to run on a shared runner. A blanket | |
| # `pkill -f socat` would also reap a socat the runner infrastructure | |
| # itself depends on, which is why #1561 recorded the leak but left | |
| # the fix alone. Nothing but ASRT creates `claude-{http,socks}-*.sock`. | |
| E2E_SOCAT_PATTERN='[s]ocat.*claude-(http|socks)-[0-9a-f]+\.sock' | |
| cleanup_e2e_processes() { | |
| # Bracketed patterns avoid matching this shell's own command line. | |
| # A failed deleteSession can orphan any of these; self-hosted runner | |
| # containers are reused, so clean before attempt 1 as well as retries. | |
| pkill -TERM -f '[e]lectron/dist/electron' 2>/dev/null || true | |
| pkill -TERM -f '[e]lectron-chromedriver/bin/chromedriver' 2>/dev/null || true | |
| pkill -TERM -f '[X]vfb' 2>/dev/null || true | |
| pkill -TERM -f '[g]ortex' 2>/dev/null || true | |
| # Added for #1442's socat bridges: the reaper predates them, so they | |
| # survived into the next job on a reused container. Each leaked | |
| # bridge holds its listening socket, and a shard that then cannot | |
| # start a session fails every spec that calls `reloadSession`. | |
| pkill -TERM -f "$E2E_SOCAT_PATTERN" 2>/dev/null || true | |
| sleep 1 | |
| pkill -KILL -f '[e]lectron/dist/electron' 2>/dev/null || true | |
| pkill -KILL -f '[e]lectron-chromedriver/bin/chromedriver' 2>/dev/null || true | |
| pkill -KILL -f '[X]vfb' 2>/dev/null || true | |
| pkill -KILL -f '[g]ortex' 2>/dev/null || true | |
| pkill -KILL -f "$E2E_SOCAT_PATTERN" 2>/dev/null || true | |
| # The sockets outlive the processes that listened on them. Leaving | |
| # them behind fills $TMPDIR on a long-lived runner and makes the | |
| # diagnostics harder to read. | |
| find "${TMPDIR:-/tmp}" -maxdepth 1 \ | |
| \( -name 'claude-http-*.sock' -o -name 'claude-socks-*.sock' \) \ | |
| -delete 2>/dev/null || true | |
| } | |
| capture_e2e_runner_diagnostics() { | |
| local attempt="$1" | |
| local attempt_status="$2" | |
| local diagnostics_dir="e2e-failure-artifacts" | |
| local diagnostics_file="${diagnostics_dir}/runner-attempt-${attempt}.txt" | |
| mkdir -p "$diagnostics_dir" | |
| { | |
| echo "captured_at=$(date --iso-8601=seconds 2>/dev/null || date)" | |
| echo "attempt=$attempt" | |
| echo "exit_status=$attempt_status" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "runner_environment=${RUNNER_ENVIRONMENT:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "shard=${{ matrix.shard }}/${SHARD_TOTAL}" | |
| echo | |
| echo "## system" | |
| uname -a || true | |
| cat /etc/hostname 2>/dev/null || true | |
| echo | |
| echo "## cgroup" | |
| # Docker uses cgroup v2 on the burst hosts, while the v1 files | |
| # keep the artifact useful on older/self-managed Linux runners. | |
| for cgroup_file in \ | |
| /sys/fs/cgroup/memory.current \ | |
| /sys/fs/cgroup/memory.peak \ | |
| /sys/fs/cgroup/memory.max \ | |
| /sys/fs/cgroup/memory.events \ | |
| /sys/fs/cgroup/memory.events.local \ | |
| /sys/fs/cgroup/pids.current \ | |
| /sys/fs/cgroup/pids.peak \ | |
| /sys/fs/cgroup/pids.max \ | |
| /sys/fs/cgroup/cpu.stat \ | |
| /sys/fs/cgroup/io.stat \ | |
| /sys/fs/cgroup/memory/memory.usage_in_bytes \ | |
| /sys/fs/cgroup/memory/memory.max_usage_in_bytes \ | |
| /sys/fs/cgroup/memory/memory.limit_in_bytes \ | |
| /sys/fs/cgroup/memory/memory.failcnt \ | |
| /sys/fs/cgroup/pids/pids.current \ | |
| /sys/fs/cgroup/pids/pids.max; do | |
| if [ -r "$cgroup_file" ]; then | |
| echo "### $cgroup_file" | |
| cat "$cgroup_file" | |
| fi | |
| done | |
| echo | |
| echo "## memory" | |
| free -h || true | |
| echo | |
| echo "## processes by RSS" | |
| ps -eo pid,ppid,state,etimes,rss,vsz,comm --sort=-rss | head -n 80 || true | |
| echo | |
| # An RSS-sorted top-80 hides light, numerous processes — which is | |
| # exactly the shape a leak takes. socat in particular is tiny, so a | |
| # runner carrying dozens of orphans looks unremarkable above. | |
| echo "## process counts by name" | |
| ps -eo comm= | sort | uniq -c | sort -rn | head -n 30 || true | |
| echo | |
| echo "## e2e / sandbox process detail (orphans have ppid 1)" | |
| ps -eo pid,ppid,etimes,comm,args \ | |
| | grep -E '[s]ocat|[b]wrap|[e]lectron|[c]hromedriver|[X]vfb' \ | |
| | head -n 60 || true | |
| echo | |
| # ASRT needs bubblewrap AND socat, and bwrap additionally needs | |
| # usable unprivileged user namespaces at runtime (see | |
| # ci-runners/docker-compose.yml). When these are wrong the app does | |
| # not crash — it degrades to sandbox-off — so the fault is invisible | |
| # unless captured here. | |
| echo "## sandbox preconditions" | |
| echo "socat=$(command -v socat || echo MISSING)" | |
| echo "bwrap=$(command -v bwrap || echo MISSING)" | |
| bwrap --version 2>&1 || true | |
| for sysctl_file in \ | |
| /proc/sys/kernel/apparmor_restrict_unprivileged_userns \ | |
| /proc/sys/kernel/unprivileged_userns_clone \ | |
| /proc/sys/user/max_user_namespaces; do | |
| if [ -r "$sysctl_file" ]; then | |
| echo "$sysctl_file=$(cat "$sysctl_file")" | |
| else | |
| echo "$sysctl_file=UNREADABLE" | |
| fi | |
| done | |
| # The same shape ASRT asks for. Proves the chain rather than just | |
| # the binary's presence. | |
| echo "### bwrap probe" | |
| bwrap --unshare-all --dev-bind / / --die-with-parent true 2>&1 \ | |
| && echo "bwrap_probe=ok" || echo "bwrap_probe=FAILED" | |
| echo | |
| echo "## open file descriptors" | |
| echo "fd_nr=$(cat /proc/sys/fs/file-nr 2>/dev/null || echo unknown)" | |
| echo | |
| echo "## filesystems" | |
| df -h / /tmp /dev/shm || true | |
| echo | |
| echo "## limits" | |
| ulimit -a || true | |
| } >"$diagnostics_file" 2>&1 | |
| echo "Saved runner diagnostics to $diagnostics_file" | |
| # Also print it. The artifact is the durable copy, but downloading a | |
| # workflow artifact needs a token with `actions:read`, which a GitHub | |
| # App integration does not get — so for anyone triaging through the | |
| # API the file may as well not exist (`403 Resource not accessible by | |
| # integration`). The job log has no such gate. Collapsed by default, | |
| # so a green-eyed scroll past a failing shard costs nothing. | |
| echo "::group::runner diagnostics (attempt $attempt)" | |
| cat "$diagnostics_file" || true | |
| echo "::endgroup::" | |
| } | |
| # Dump whatever WebdriverIO and chromedriver wrote about the session. | |
| # "Unable to connect to http://localhost:PORT" tells us the driver | |
| # stopped accepting — it never says why, because the only account of | |
| # that is the driver's own log, which lives in wdio's outputDir and was | |
| # being discarded at job end. | |
| # | |
| # Filtered, not tailed. The job-log API returns roughly the last 5000 | |
| # lines, so a raw tail of ~20 driver logs (more with chromedriver's | |
| # `verbose`) crowds out everything printed after it — which is how both | |
| # the spec summary and the runner diagnostics became unreachable | |
| # through the API even though they were right there in the file. Print | |
| # the error-adjacent lines instead, and fall back to a raw tail only | |
| # when nothing matched, so a novel failure is never silently dropped. | |
| # The uploaded artifact remains the complete record. | |
| E2E_LOG_SIGNALS='ERROR|WARN|Unable to connect|ECONNREFUSED|ECONNRESET|EPIPE|UND_ERR|bind\(\) failed|Cannot start http server|DevTools HTTP Request failed|aborted due to timeout|Timed out receiving message|no such session|invalid session id|session not created|boot-complete|was started successfully' | |
| dump_e2e_session_logs() { | |
| local logs_dir="e2e-failure-artifacts/wdio-logs" | |
| [ -d "$logs_dir" ] || return 0 | |
| echo "::group::wdio + chromedriver logs (attempt $1)" | |
| for log_file in "$logs_dir"/*.log; do | |
| [ -f "$log_file" ] || continue | |
| echo "### $log_file" | |
| # Capture first: `grep … | tail` exits with *tail's* status, which | |
| # is 0 even when grep matched nothing, so testing the pipeline | |
| # directly would make the fallback dead code and print nothing at | |
| # all for a log with no known signal — the exact case it exists for. | |
| matched="$(grep -nE "$E2E_LOG_SIGNALS" "$log_file" | tail -n 60)" | |
| if [ -n "$matched" ]; then | |
| printf '%s\n' "$matched" | |
| else | |
| echo "(no signal lines matched — raw tail)" | |
| tail -n 60 "$log_file" || true | |
| fi | |
| echo | |
| done | |
| echo "::endgroup::" | |
| } | |
| # A failing spec reports what it could not see; the renderer console | |
| # reports why. `afterTest` already captures it — one `.console.log` per | |
| # failing test — but only into the uploaded artifact, which a triager | |
| # reading the job log never opens. That gap cost three days on #1925: | |
| # `automation-trigger` said "the scheduled run's prompt never rendered" | |
| # while the console said `Base branch "work" does not exist in this | |
| # repository`, and only the second sentence names a fix. | |
| # | |
| # SEVERE only, and capped hard on both axes — this rides inside the | |
| # digest, whose whole value is being small enough to survive the log | |
| # tail. The artifact stays the complete record. | |
| summarise_renderer_console() { | |
| local artefacts='e2e-failure-artifacts' severe | |
| [ -d "$artefacts" ] || return 0 | |
| severe="$(grep -h '"level":"SEVERE"' "$artefacts"/*.console.log 2>/dev/null | tail -n 15 || true)" | |
| [ -n "$severe" ] || return 0 | |
| echo | |
| echo "renderer console (SEVERE, last 15):" | |
| # Long lines are usually a stack trace after the message; the first | |
| # 300 characters carry the error itself — but only once the | |
| # boilerplate in front of it is gone. Every line opens with a | |
| # `file:///…/dist/renderer/app.js 292986:14 ` source location, and | |
| # anything thrown through `ipcMain.handle` adds an `Error invoking | |
| # remote method 'x:y': ` wrapper: together ~150 characters that say | |
| # nothing and push the cause off the end. On run 33031502487 this | |
| # printed `submodules unsupported (/opt/runner/_work` and stopped | |
| # one character before the directory it had found — the single fact | |
| # the whole digest existed to report. | |
| printf '%s\n' "$severe" \ | |
| | sed -E "s#file://[^ ]+ [0-9]+:[0-9]+ ##; s#Error invoking remote method '[^']+': ##" \ | |
| | cut -c1-300 | |
| } | |
| # The last thing the job prints, and deliberately not inside a group. | |
| # | |
| # Everything above competes for the job-log API's ~5000-line tail; this | |
| # does not, because it is last and it is small. It re-states the two | |
| # facts a triager always wants first — how many specs passed, and which | |
| # ones failed with what — so they survive however large the dumps get. | |
| summarise_e2e_attempt() { | |
| local attempt="$1" run_log="$2" | |
| [ -f "$run_log" ] || return 0 | |
| echo "── shard ${{ matrix.shard }} attempt ${attempt} digest ─────────────────" | |
| grep -E '^Spec Files:' "$run_log" | tail -n 1 || true | |
| # The reporter prints a `» <spec>` header for every file it runs, so | |
| # keeping them all buried the one line that mattered under twenty | |
| # that did not (run 31243627118 shard 2 printed all 21 paths for a | |
| # single failing spec). And the error detail carries the same | |
| # `[chrome …]` prefix as everything else, so anchoring on leading | |
| # whitespace matched none of it — the digest named the spec but never | |
| # said what failed. | |
| # | |
| # Select by worker instead: take the prefixes that reported a | |
| # failure, then print those workers' lines whole. Header, verdict and | |
| # error arrive together, whatever shape the error takes. | |
| local failing | |
| failing="$(grep -oE '^\[[^]]+\] [0-9]+ failing' "$run_log" | grep -oE '^\[[^]]+\]' | sort -u)" | |
| if [ -n "$failing" ]; then | |
| # Name every failing spec before printing any detail. The detail is | |
| # capped, and a cap measured in lines drops whole workers once more | |
| # than one spec fails: run 31266338302 shard 6 reported "3 failed" | |
| # and named exactly one, because `tail -n 40` reached back only as | |
| # far as the last worker's block. The names are what a reader needs | |
| # first and they cost one line each, so they are never truncated. | |
| # | |
| # The raw wdio output lives in $run_log, which is not uploaded and | |
| # not in the job log, so anything this function drops is gone. | |
| echo "failing specs:" | |
| printf '%s\n' "$failing" | grep -F -f - "$run_log" | grep -F '» ' | sort -u || true | |
| echo | |
| printf '%s\n' "$failing" | grep -F -f - "$run_log" | tail -n 120 || true | |
| summarise_renderer_console | |
| else | |
| # No worker reported a failure, so the shard died some other way — | |
| # a timeout, or a session that never came up. The raw tail is all | |
| # there is, and printing nothing here is how a novel failure mode | |
| # becomes invisible. | |
| echo "(no per-spec failure reported — raw tail)" | |
| tail -n 30 "$run_log" || true | |
| fi | |
| echo "──────────────────────────────────────────────────────────" | |
| } | |
| trap cleanup_e2e_processes EXIT | |
| # Two attempts, not three: attempt 2 absorbs the flaky-session-startup | |
| # case this retry exists for, while a deterministic failure (e.g. a | |
| # selector that can never match) previously burned three full 480s | |
| # attempts before reporting. Halves the cost of a genuine red shard. | |
| for attempt in 1 2; do | |
| cleanup_e2e_processes | |
| run_log="e2e-failure-artifacts/attempt-${attempt}.log" | |
| mkdir -p e2e-failure-artifacts | |
| echo "::group::e2e shard ${{ matrix.shard }} attempt $attempt" | |
| # Tee so the reporter output can be re-read for the digest below. | |
| # PIPESTATUS, not $?, or we would read tee's status instead. | |
| ${TIMEOUT:+$TIMEOUT -k 15 480} npm run test:e2e:ci -- $SHARD_ARGS $SPEC_ARGS 2>&1 \ | |
| | tee "$run_log" | |
| attempt_status=${PIPESTATUS[0]} | |
| echo "::endgroup::" | |
| if [ "$attempt_status" -eq 0 ]; then | |
| exit 0 | |
| fi | |
| # Order matters as much as volume: the job-log API returns the tail, | |
| # so the biggest output goes first and the smallest, most useful | |
| # output goes last. Bulk dumps, then diagnostics, then the digest. | |
| dump_e2e_session_logs "$attempt" || true | |
| capture_e2e_runner_diagnostics "$attempt" "$attempt_status" || true | |
| summarise_e2e_attempt "$attempt" "$run_log" || true | |
| echo "e2e shard ${{ matrix.shard }} attempt $attempt failed or timed out" | |
| done | |
| exit 1 | |
| # The specs above call saveScreenshot as they run, so this shard's slice has | |
| # already re-rendered its reference PNGs into tests/e2e/screenshots/. Collect | |
| # whatever changed (vs the committed shots) and hand it to the immutable | |
| # candidate collector — no second e2e suite needed. PR-only; the sentinel keeps the artifact | |
| # non-empty when a shard rendered no diffs. | |
| - name: Collect changed reference screenshots | |
| if: success() && github.event_name == 'pull_request' | |
| run: | | |
| mkdir -p changed/tests/e2e/screenshots | |
| touch "changed/.shard-${{ matrix.shard }}" | |
| # Untracked PNGs are first-time reference shots from a new spec and | |
| # must be included alongside modifications. | |
| mapfile -t files < <( | |
| { | |
| git diff --name-only -- tests/e2e/screenshots/ | |
| git ls-files --others --exclude-standard -- tests/e2e/screenshots/ | |
| } | sort -u | |
| ) | |
| for f in "${files[@]}"; do | |
| [ -n "$f" ] || continue | |
| cp "$f" "changed/tests/e2e/screenshots/" | |
| done | |
| echo "Collected ${#files[@]} changed screenshot(s) for shard ${{ matrix.shard }}" | |
| - uses: actions/upload-artifact@v7 | |
| if: success() && github.event_name == 'pull_request' | |
| with: | |
| name: screenshots-shard-${{ matrix.shard }} | |
| path: changed/ | |
| include-hidden-files: true | |
| if-no-files-found: error | |
| # Only consumed by screenshot-artifacts within this same run; it never | |
| # needs to outlive the run. Uploaded per shard on every PR, so the | |
| # default 90-day retention made this the largest draw on the org's | |
| # Actions storage quota. Expire fast, keeping a small debug window. | |
| retention-days: 3 | |
| # On failure, surface the screenshot + page source captured in afterTest, | |
| # plus the per-attempt cgroup/process snapshot above. The latter is still | |
| # available when ChromeDriver is too dead to capture browser artifacts. | |
| # Skipped only when a hard runner OOM kills the whole job before this step. | |
| - uses: actions/upload-artifact@v7 | |
| if: failure() | |
| continue-on-error: true | |
| with: | |
| name: e2e-failure-artifacts-shard-${{ matrix.shard }} | |
| path: e2e-failure-artifacts/ | |
| if-no-files-found: ignore | |
| retention-days: 3 | |
| # Preserve reference screenshots as immutable run evidence. Each e2e shard | |
| # uploads only PNGs that differ from the checked-out baseline; this job merges | |
| # those slices into one downloadable candidate set. It deliberately has no | |
| # write permission and never checks out or pushes the PR branch. The trusted | |
| # publish-screenshot-candidates workflow consumes the finished artifact, opens | |
| # a bot-owned child PR into the source branch, and links it from the parent for | |
| # explicit image-diff review. Forks retain the artifact-only/manual path. | |
| screenshot-artifacts: | |
| needs: [build, e2e] | |
| if: >- | |
| always() && needs.build.result == 'success' && needs.e2e.result == 'success' && | |
| github.event_name == 'pull_request' | |
| runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }} | |
| # Raised from 10 to cover the checkout + dependency install that the noise | |
| # filter below needs. Both are cache-warm on the fleet; the budget is for a | |
| # cold runner, not the steady state. | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| steps: | |
| # Checkout FIRST: `actions/checkout` cleans the workspace, so downloading | |
| # the shard artifacts before it would wipe them. No `ref:` — the default | |
| # merge commit is what the e2e shards rendered against, so the filter's | |
| # `git diff` reproduces exactly the comparison they made. `fetch-depth: 0` | |
| # for the filter's flap lookback and its `origin/main` scope base. | |
| - uses: actions/checkout@v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| # Mirrors precheck: never leave even the read-only GITHUB_TOKEN in a | |
| # fork checkout, but keep it for same-repo runs, which is the only | |
| # shape `./.github/actions/setup` is proven against in this pipeline. | |
| persist-credentials: ${{ github.event.pull_request.head.repo.full_name == github.repository }} | |
| - uses: ./.github/actions/setup | |
| - name: Download rendered screenshot candidates | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: screenshots-* | |
| merge-multiple: true | |
| path: candidates | |
| # Reference PNGs are pixel-rendered on the runner, so a re-render of an | |
| # UNCHANGED screen still differs by a handful of anti-aliased pixels. Left | |
| # unfiltered every PR ships a review PR of near-wholesale re-renders — | |
| # measured at 280 shots on #2134, a PR that changed no source file at all, | |
| # of which `filter-screenshots` classified 0 as real. Reviewers cannot find | |
| # signal in that, so they rubber-stamp it. | |
| # | |
| # `scripts/filter-screenshots.mts` (#609) already encodes the keep / | |
| # ignore / flap decision and was written for exactly this, but it was only | |
| # ever documented as a manual local step. Run it here, in the job that | |
| # merges the shard slices, so the candidate artifact carries only real | |
| # changes and the downstream review PR is worth opening. | |
| - name: Drop render noise from the candidate set | |
| env: | |
| # The filter's documented escape hatch: the label means "refresh | |
| # everything", including sub-threshold micro-diffs. | |
| UPDATE_SCREENSHOTS_LABEL: >- | |
| ${{ contains(github.event.pull_request.labels.*.name, 'update-screenshots') }} | |
| run: | | |
| set -euo pipefail | |
| shots='candidates/tests/e2e/screenshots' | |
| if ! compgen -G "$shots/*.png" > /dev/null; then | |
| echo 'No candidate PNGs to filter.' | |
| exit 0 | |
| fi | |
| # Lay the re-renders over the checked-out baseline. A plain copy, not | |
| # `git checkout` — the latter writes the index too, and the filter | |
| # reads the UNSTAGED working-tree diff (`git diff --name-only`), so a | |
| # staged candidate is invisible to it and would be silently kept. | |
| cp "$shots"/*.png tests/e2e/screenshots/ | |
| # Restores every sub-threshold shot to its committed bytes in place. | |
| node scripts/filter-screenshots.mts | |
| # Whatever the filter restored no longer differs from the baseline, so | |
| # drop it from the artifact. Iterating the artifact (not the worktree) | |
| # keeps the per-shard `.shard-N` provenance markers untouched. | |
| for png in "$shots"/*.png; do | |
| rel="tests/e2e/screenshots/$(basename "$png")" | |
| if git diff --quiet -- "$rel" 2>/dev/null && | |
| ! git ls-files --others --exclude-standard --error-unmatch "$rel" > /dev/null 2>&1 | |
| then | |
| rm -f "$png" | |
| fi | |
| done | |
| - name: Summarize candidate screenshots | |
| id: candidates | |
| run: | | |
| mapfile -t files < <( | |
| find candidates/tests/e2e/screenshots -type f -name '*.png' 2>/dev/null | sort | |
| ) | |
| echo "count=${#files[@]}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "## E2E screenshot candidates" | |
| echo | |
| if [ ${#files[@]} -eq 0 ]; then | |
| echo "All rendered screenshots match the committed references." | |
| else | |
| echo "${#files[@]} changed reference screenshot(s) are attached as an immutable artifact." | |
| echo | |
| echo "A trusted follow-up workflow will put these PNGs on a bot-owned branch," | |
| echo "open a child PR into the source branch, and link it from the parent for review." | |
| echo "The artifact remains the immutable record and the manual fallback for forks." | |
| echo | |
| printf -- '- `%s`\n' "${files[@]#candidates/}" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload immutable screenshot candidates | |
| if: steps.candidates.outputs.count != '0' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: reference-screenshot-candidates-${{ github.run_id }} | |
| # Preserve the repository-relative tree so extracting over a checkout | |
| # places the PNGs exactly where the reviewer expects. | |
| path: candidates/ | |
| if-no-files-found: error | |
| retention-days: 14 | |
| # Single aggregate gate. Require ONLY this check in branch protection: it | |
| # `needs` every other job (and e2e's result aggregates all 8 shards), so it | |
| # turns the whole pipeline into one green/red status. New jobs or e2e shard | |
| # counts can change without touching the required-checks list — just add the | |
| # job to `needs` here. `if: !cancelled()` so this runs even when an upstream | |
| # job fails/is skipped; we then assert none failed or were cancelled. Fork PRs | |
| # have their own explicit safe-tier result below: they may pass only after | |
| # `precheck` actually ran on a GitHub-hosted runner, never merely because all | |
| # code-executing jobs were skipped. | |
| # | |
| # Concurrency supersession: top-level `cancel-in-progress` cancels in-flight | |
| # jobs when a newer push/sync arrives on the same ref. Those cancelled jobs | |
| # must not paint the superseded SHA's `CI Passed` red — tip CI is the gate | |
| # that matters. Two different mechanisms cover that, and which one applies | |
| # depends on whether the RUN was cancelled or only some of its JOBS were: | |
| # | |
| # - Whole run cancelled (concurrency supersession, manual cancel): this job | |
| # is SKIPPED by the `if:` below and never reports. The superseding run's | |
| # own `CI Passed` is the one that counts. | |
| # - Individual jobs cancelled while the run itself lives on (skip-mode's | |
| # check/build/e2e — issue #609 — matrix cancels, a runner losing | |
| # communication): this job still runs, and the `ANY_CANCELLED` branch in | |
| # the step below decides. It detects supersession by (1) comparing this | |
| # run's head SHA to the live tip, and (2) looking for a newer CI run on | |
| # the same head SHA (e.g. a delayed nightly schedule sharing | |
| # `ci-refs/heads/main` with a tip push). A job-level cancel still at tip | |
| # with no newer run fails. Sibling-matrix cancels after a real shard | |
| # failure fail because `ANY_FAILURE` is true first. | |
| # | |
| # KNOWN CONSEQUENCE of the first bullet: a skipped required check satisfies | |
| # branch protection, so a manually cancelled run now reads as satisfied rather | |
| # than red. That is a deliberate trade for unwedging the concurrency group | |
| # (see the `if:` below); the case it costs is a human cancelling a run they | |
| # do not intend to re-run, which the next push re-gates anyway. | |
| ci-passed: | |
| # A push to trunk (`main`) intentionally skips the expensive tier. Give that | |
| # run a distinct check context so its green result on the same head SHA | |
| # cannot satisfy `release`'s required `CI Passed` check before promotion e2e | |
| # has run. | |
| # | |
| # The literal name stays `Develop CI Passed` even though trunk is no longer | |
| # called `develop`: it is a check-context string that branch protection may | |
| # match on by name, so renaming it is a separate change that has to land | |
| # together with the protection rule. | |
| # | |
| # Fork PRs get their own context for the same reason, and it closes the last | |
| # of #800's acceptance criteria: "branch protection distinguishes 'tested and | |
| # passed' from 'not executed'". A fork run is genuinely green — `precheck` | |
| # executes the fork's typecheck and lint on a hosted runner, and gitleaks.yml | |
| # scans it — but `check`, `build` and `e2e` never dispatch, so unit tests, the | |
| # bundle and the whole e2e tier are unrun. Publishing that under the same | |
| # `CI Passed` string as a fully-tested same-repo run made the two | |
| # indistinguishable to branch protection and to anyone reading the checks | |
| # list. Now a fork PR simply has no `CI Passed` check, so a rule requiring one | |
| # holds the merge until a maintainer takes the change through a same-repo | |
| # branch — the same mechanism that stops a trunk push satisfying `release`. | |
| # | |
| # Additive on purpose: same-repo PRs still publish `CI Passed` byte-for-byte, | |
| # so no existing protection rule or consumer changes meaning. (`release-mac.yml` | |
| # matches this name for a release SHA, which is never a fork PR.) | |
| name: >- | |
| ${{ (github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.full_name != github.repository) && 'Fork CI Passed' || | |
| github.event_name == 'push' && github.ref == 'refs/heads/main' && 'Develop CI Passed' || | |
| 'CI Passed' }} | |
| # NOT `always()`. That is true even when the RUN is cancelled, so GitHub | |
| # still creates and queues this job on a run that is being torn down — and | |
| # `runs-on` below prefers the self-hosted fleet, where it can then wait for | |
| # a runner that is never coming. A queued job keeps its run out of a | |
| # terminal state, the run keeps holding the `ci-<pr>` concurrency group, and | |
| # the run that superseded it therefore never starts: it sits `pending` with | |
| # zero jobs created. Observed on #1669, where promoting a draft cancelled | |
| # the in-flight run and CI was wedged for 114 minutes — until a fleet runner | |
| # freed up and the orphan finally ran, correctly reporting "superseded" in | |
| # about five seconds. That is the point: the job was never broken, it just | |
| # should not have been queued for a runner at all on a run nobody was | |
| # waiting for. Meanwhile a normal cancel is accepted (202) but does not | |
| # dequeue it, and `force-cancel` is the only manual way out. | |
| # | |
| # `!cancelled()` keeps everything `always()` was buying — this still runs | |
| # when an upstream job FAILS or is SKIPPED, which the default `success()` | |
| # would not — and differs only on a cancelled run, where being skipped is | |
| # what lets the run finish and release the group. GitHub documents this | |
| # exact substitution as the fix for `always()` hangs. | |
| if: ${{ !cancelled() }} | |
| needs: [precheck, check, bench, build, e2e, screenshot-artifacts] | |
| # Tiny aggregate gate. Hosted by default; SELF_HOSTED_CHECKS=copse-checks | |
| # opts it onto the self-hosted check fleet. Fails closed either way: if the fleet | |
| # is saturated this stays pending (never green), so it can't wave a run | |
| # through without its required status check. | |
| # | |
| # "Stays pending" is the right answer for a LIVE run and the wrong one for a | |
| # cancelled run, where it wedges the concurrency group — which is why the | |
| # `if:` above must never go back to `always()` while this prefers the fleet. | |
| # Pinning this one job to `ubuntu-latest` is the other way to buy that (it | |
| # would start in seconds and could never queue behind the fleet), at roughly | |
| # one billed hosted minute per run; deliberately not taken. | |
| # | |
| # Fork PRs go to GitHub-hosted regardless: fork runs must stay off the | |
| # fleet entirely, and the fleet's runner group should not serve public-repo | |
| # jobs anyway — routing there would queue this gate forever. | |
| runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'ubuntu-latest' || vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }} | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| actions: read | |
| steps: | |
| - name: Check upstream job results | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPOSITORY: ${{ github.repository }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| REF_NAME: ${{ github.ref_name }} | |
| RUN_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} | |
| PR_NUMBER: ${{ github.event.pull_request.number || '' }} | |
| CURRENT_RUN_ID: ${{ github.run_id }} | |
| # Self-hosted check-fleet images ship curl+jq but not the gh CLI | |
| # (ci-runners/Dockerfile). #1017 moved this gate back onto that fleet, | |
| # so tip/supersession lookups must use the REST API — `gh api` fails | |
| # with `gh: command not found` and the gate fail-closes on every | |
| # concurrency cancel (false red on main). | |
| GITHUB_API_URL: ${{ github.api_url }} | |
| run: | | |
| # Thin REST helper — curl+jq are on both hosted and self-hosted images. | |
| api_get() { | |
| curl -fsS \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "${GITHUB_API_URL}/$1" | |
| } | |
| FORK_PR=${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} | |
| if $FORK_PR; then | |
| if [ "${{ needs.precheck.result }}" != "success" ]; then | |
| echo "Fork PR safe check tier was not executed successfully." | |
| echo '${{ toJSON(needs) }}' | |
| exit 1 | |
| fi | |
| echo "Fork PR safe check tier passed (typecheck, lint, format, API protocol)." | |
| echo "Unit tests, build and e2e were intentionally not dispatched: fork code" | |
| echo "must not run on this repository's runners. This run therefore reports as" | |
| echo "'Fork CI Passed', not 'CI Passed' — it is not evidence the change is tested." | |
| exit 0 | |
| fi | |
| MODE="${{ needs.precheck.outputs.mode }}" | |
| ANY_FAILURE=${{ contains(needs.*.result, 'failure') }} | |
| ANY_CANCELLED=${{ contains(needs.*.result, 'cancelled') }} | |
| CORE_OK=${{ needs.precheck.result == 'success' }} | |
| # Skip-mode runs (human-reviewed screenshot-only commits) intentionally | |
| # do not execute check/build/e2e; | |
| # those jobs surface as 'cancelled', which must not fail the gate — | |
| # that painted every bot push red and blocked auto-merge (issue | |
| # #609). Genuine failures still fail even in skip mode. | |
| if [ "$MODE" = "skip" ]; then | |
| if $ANY_FAILURE || ! $CORE_OK; then | |
| echo "Skip-mode run has a genuine failure:" | |
| echo '${{ toJSON(needs) }}' | |
| exit 1 | |
| fi | |
| echo "All CI jobs passed (heavy jobs intentionally skipped: mode=skip)." | |
| exit 0 | |
| fi | |
| # Demand e2e whenever the job's own dispatch contract says a | |
| # merge-eligible same-repository PR must run it. Mirroring the draft | |
| # and zero-shard guards avoids demanding a deliberately skipped job. | |
| E2E_REQUIRED=${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ci-full')) }} | |
| if $E2E_REQUIRED && [ "${{ needs.precheck.outputs.e2e_shard_total }}" != "0" ] && [ "${{ needs.e2e.result }}" != "success" ]; then | |
| echo "Required PR e2e did not complete successfully (result=${{ needs.e2e.result }})." | |
| echo '${{ toJSON(needs) }}' | |
| exit 1 | |
| fi | |
| if $ANY_FAILURE; then | |
| echo "One or more CI jobs failed:" | |
| echo '${{ toJSON(needs) }}' | |
| exit 1 | |
| fi | |
| if $ANY_CANCELLED; then | |
| # Fail closed if we cannot resolve tip — a tip cancel must stay red. | |
| if [ -n "$PR_NUMBER" ]; then | |
| TIP_SHA=$(api_get "repos/${REPOSITORY}/pulls/${PR_NUMBER}" | jq -er .head.sha) || { | |
| echo "Could not resolve PR tip SHA; treating cancel as failure." | |
| echo '${{ toJSON(needs) }}' | |
| exit 1 | |
| } | |
| else | |
| ENC_REF=$(printf '%s' "$REF_NAME" | jq -sRr @uri) | |
| TIP_SHA=$(api_get "repos/${REPOSITORY}/commits/${ENC_REF}" | jq -er .sha) || { | |
| echo "Could not resolve branch tip SHA; treating cancel as failure." | |
| echo '${{ toJSON(needs) }}' | |
| exit 1 | |
| } | |
| fi | |
| if [ -n "$TIP_SHA" ] && [ "$TIP_SHA" != "$RUN_HEAD_SHA" ]; then | |
| echo "Run superseded by newer tip ${TIP_SHA} (this SHA=${RUN_HEAD_SHA}); cancelled jobs are concurrency noise." | |
| echo '${{ toJSON(needs) }}' | |
| exit 0 | |
| fi | |
| # Same tip, but a newer CI run on this SHA may have taken the | |
| # concurrency slot (delayed schedule, re-run, workflow_dispatch). | |
| NEWER_RUN_ID=$(api_get \ | |
| "repos/${REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${RUN_HEAD_SHA}&per_page=20" \ | |
| | jq -r "[.workflow_runs[] | select(.id > ${CURRENT_RUN_ID}) | .id][0] // empty") || { | |
| echo "Could not resolve newer workflow runs; treating cancel as failure." | |
| echo '${{ toJSON(needs) }}' | |
| exit 1 | |
| } | |
| if [ -n "$NEWER_RUN_ID" ]; then | |
| echo "Run superseded by newer CI run ${NEWER_RUN_ID} on tip ${RUN_HEAD_SHA}; cancelled jobs are concurrency noise." | |
| echo '${{ toJSON(needs) }}' | |
| exit 0 | |
| fi | |
| echo "One or more CI jobs were cancelled while still at tip (${RUN_HEAD_SHA}):" | |
| echo '${{ toJSON(needs) }}' | |
| exit 1 | |
| fi | |
| echo "All CI jobs passed (or were intentionally skipped)." |