test(a2a): promote the A2A server batch to @stable (#1349) #1102
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: PR Validation | |
| on: | |
| pull_request: | |
| branches: [main] | |
| jobs: | |
| typecheck: | |
| name: TypeScript Check | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: "20" | |
| cache: "npm" | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: TypeScript check | |
| run: npm run typecheck | |
| # Unit tests for the dependency-free helpers under scripts/ — plain | |
| # `node --test`, no Playwright and no backend, so they belong in this fast | |
| # job rather than the E2E one. These cover CI-critical logic that had no | |
| # test job at all before: the duration-balanced shard partitioner (#936) | |
| # and the report-integrity guard (#1012). A guard whose own test never | |
| # runs is a guard nobody is guarding. | |
| - name: Script unit tests | |
| run: npm run test:scripts | |
| # The TypeScript half of the same idea (#1017). Everything written in `.ts` | |
| # was ungated: the `@stable` parser two generators read as the release | |
| # signal (#985), the script that EDITS SPECS AND COMMITS TO `main` with no | |
| # human review, and the collect-models fallback whose scenario matrix was | |
| # validated in a scratch file outside the repo (#1011). | |
| # | |
| # Same runner as the lane above — `node --test` — via ts-node's require | |
| # hook, so no new dependency and no second runner to keep current. The hook | |
| # (not the deprecated `--loader ts-node/esm`) is what makes this work on the | |
| # Node 20 this workflow pins; the trade-off and the rejected options are | |
| # recorded in CONTRIBUTING.md → "Unit tests". | |
| - name: TypeScript unit tests | |
| run: npm run test:units | |
| # The REGRESSIONS.md indicator block is generated and committed by hand. | |
| # Fail the PR when a row was added without regenerating it, so the | |
| # headline number can never silently disagree with the table. | |
| - name: Regression Ledger indicator in sync | |
| run: npm run regressions:check | |
| lint: | |
| name: ESLint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: "20" | |
| cache: "npm" | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Lint | |
| run: npm run lint:ci | |
| # The repo's own Python code had no lane at all, which is how `generate_report.py` | |
| # shipped a report that said `Result: PASSED` on a run whose UI phase had failed | |
| # (#1120, run #115). The two `test:units` / `test:scripts` lanes only cover | |
| # TypeScript and `.mjs`, so nothing here was verifiable before merge. | |
| python-units: | |
| name: Python unit tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: "3.12" | |
| - name: Install pytest | |
| run: pip install --quiet pytest | |
| - name: Migration helper unit tests | |
| run: | | |
| # `test_ui_migration.py` is excluded, not missing: it drives a real browser | |
| # against a migrated Langflow and belongs to migration-test.yml. Everything | |
| # else under this path is a plain unit test, so a new one is picked up | |
| # without touching this workflow. | |
| # | |
| # Fail loudly on an empty selection, like the two Node lanes do — a unit | |
| # lane that silently runs nothing is worse than no lane (#1012's rule). | |
| COUNT=$(python -m pytest tests/github-workflows/migration \ | |
| --ignore=tests/github-workflows/migration/test_ui_migration.py \ | |
| --collect-only -q 2>/dev/null | grep -cE '::' || true) | |
| if [ "${COUNT:-0}" -eq 0 ]; then | |
| echo "::error::no Python unit tests were collected — this lane would have passed without verifying anything." | |
| exit 1 | |
| fi | |
| echo "collected $COUNT Python unit test(s)" | |
| python -m pytest tests/github-workflows/migration \ | |
| --ignore=tests/github-workflows/migration/test_ui_migration.py -v | |
| checklist-guard: | |
| name: QA-CHECKLIST guard | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 6 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: "20" | |
| cache: "npm" | |
| # Fail the PR if it edits the AUTO-GENERATED blocks of QA-CHECKLIST.md | |
| # (Coverage Summary table + note, Phase 0 list, Phase 1/2 tables). Those | |
| # regenerate on merge via update-coverage-summary.yml; PRs must edit only | |
| # the manual Part II bullets, so concurrent PRs don't collide on the | |
| # generated count lines (issue #741). No npm install needed — pure git+node, | |
| # so it runs before the install step and reports fast. | |
| - name: Guard QA-CHECKLIST.md generated blocks | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| git fetch origin "$BASE_REF" --quiet | |
| node scripts/check-checklist-guard.mjs "origin/$BASE_REF" | |
| # The coverage guard below parses `@stable` out of the spec ASTs, so it | |
| # needs ts-node + typescript. `--ignore-scripts` skips Playwright's | |
| # browser download — nothing here drives a browser. | |
| - name: Install dependencies | |
| run: npm ci --ignore-scripts | |
| # Fail the PR when a spec CLAIMS coverage (carries `@stable`, or has a spec | |
| # doc under docs/) but no manual Part II bullet references it — such a spec | |
| # is invisible in every generated count, including the @stable lane the | |
| # daily workflow reads as release signal (issue #985). Whole-repo check, not | |
| # diff-based: main is kept clean, so any new drift fails on the PR that | |
| # introduces it. | |
| - name: Guard spec ↔ doc ↔ QA-CHECKLIST triad | |
| run: npm run check:checklist-coverage | |
| doc-deps-guard: | |
| name: Spec-doc dependency paths | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 6 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: "20" | |
| # The docs this PR touched. Severity turns on this list: a path in a doc the | |
| # PR changed FAILS, a pre-existing one is reported (#980's coverage-first | |
| # trade — one upstream rename must not redden every PR that edits an | |
| # unrelated doc). An empty list is normal and means nothing can fail here. | |
| - name: Collect the docs this PR changed | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| set -eo pipefail | |
| git fetch origin "$BASE_REF" --quiet | |
| git diff --name-only --diff-filter=d "origin/$BASE_REF...HEAD" -- docs README.md > all-changed.txt | |
| # `grep` exits 1 on no match, which under `pipefail` would abort a PR that | |
| # simply touches no doc — the empty list is a valid, common outcome. | |
| grep -E '\.md$' all-changed.txt > changed-docs.txt || : | |
| echo "changed docs: $(wc -l < changed-docs.txt)" | |
| cat changed-docs.txt | |
| # Trees only, no blobs, no working tree: 520 KB and ~1.6 s measured, against | |
| # ~117 MB to materialise the files. `--mode=check-docs` resolves through | |
| # `git ls-tree`, which is what makes that possible (issue #1298). | |
| - name: Clone the upstream tree | |
| run: | | |
| set -eo pipefail | |
| for attempt in 1 2 3; do | |
| if git clone --filter=blob:none --depth 1 --no-checkout \ | |
| https://github.com/langflow-ai/langflow.git langflow-upstream; then | |
| exit 0 | |
| fi | |
| echo "::warning::upstream clone attempt $attempt failed; retrying" | |
| rm -rf langflow-upstream | |
| sleep 5 | |
| done | |
| echo "::error::could not clone langflow-ai/langflow, so no dependency-path verdict exists for this PR. Not treating that as 'every path resolves'." | |
| exit 1 | |
| # Fails only on the changed docs; the repo-wide sweep is emitted as | |
| # ::warning:: so drift that lands upstream between PRs stays visible | |
| # (#1012 — an unevaluated path is unknown, not clean). | |
| - name: Resolve every External-dependencies path against upstream | |
| run: | | |
| set -eo pipefail | |
| node scripts/watch-upstream-areas.mjs \ | |
| --mode=check-docs \ | |
| --root langflow-upstream \ | |
| --ref origin/main \ | |
| --changed changed-docs.txt | tee -a "$GITHUB_STEP_SUMMARY" | |
| detect-specs: | |
| name: Detect changed specs | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| specs: ${{ steps.diff.outputs.specs }} | |
| has_specs: ${{ steps.diff.outputs.has_specs }} | |
| needs_models: ${{ steps.diff.outputs.needs_models }} | |
| full_suite: ${{ steps.diff.outputs.full_suite }} | |
| impacted_total: ${{ steps.diff.outputs.impacted_total }} | |
| dropped_count: ${{ steps.diff.outputs.dropped_count }} | |
| # True when no spec imports the diff but this lane runs what changed, so the | |
| # lane runs a fixed canary set instead of skipping (#1159). | |
| canary: ${{ steps.diff.outputs.canary }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - id: diff | |
| name: Resolve the specs this PR impacts | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| # A helper change can reach half the suite (112 specs for #1052's | |
| # diff), which is a daily-sized run on a PR. Bound it — and NEVER | |
| # silently: the dropped set is printed and summarised (#1012's | |
| # silent-cap rule). The E2E job's own budget is 60 min. | |
| IMPACTED_SPEC_CAP: "20" | |
| run: | | |
| git fetch origin "$BASE_REF" --quiet | |
| # Selection is by IMPORT GRAPH, not by changed-spec glob (#1054). | |
| # Selecting only changed `*.spec.ts` let the highest-reach change the | |
| # repo can receive pass with zero specs executed: PR #1052 changed a | |
| # helper reached by 112 specs and PR #1088 one imported by 135 — both | |
| # reported "skipping". The resolver walks importers TRANSITIVELY, so a | |
| # helper behind a Page Object still selects the specs at the end of | |
| # the chain (a direct-importers-only pass would have found 62 of | |
| # #1052's 112 and read as coverage). A changed spec selects itself, so | |
| # the old behaviour is a subset of this one — including | |
| # `tests/collect-models.spec.ts`, the file directly under `tests/` | |
| # that the `:(glob)` pathspec existed to catch (#1016/#1015/#1007). | |
| git diff --name-only --diff-filter=d "origin/$BASE_REF"...HEAD > /tmp/changed.txt | |
| echo "Changed files:"; sed 's/^/ /' /tmp/changed.txt | |
| node scripts/impacted-specs-by-import.mjs --stdin --format=json --cap "$IMPACTED_SPEC_CAP" \ | |
| < /tmp/changed.txt > /tmp/impacted.json || { echo "::error::impacted-spec resolution failed"; exit 1; } | |
| SPECS=$(jq -r '.selected | join(" ")' /tmp/impacted.json) | |
| TOTAL=$(jq -r '.specs | length' /tmp/impacted.json) | |
| DROPPED=$(jq -r '.dropped | length' /tmp/impacted.json) | |
| FULL_SUITE=$(jq -r '.fullSuite' /tmp/impacted.json) | |
| DIRECT=$(jq -r '.direct | length' /tmp/impacted.json) | |
| # `.stableSelected` is deliberately NOT read here. It is scoped to `selected` | |
| # (post-cap), so pairing it with `$TOTAL` (pre-cap) misreported it on every | |
| # capped run, and the exclusion below can shorten the set again. The `@stable` | |
| # tally now comes from the verdict's `.stableRun`, over the surviving list | |
| # (#1226). | |
| # Resolution only. The old tail — "running $TOTAL minus $DROPPED dropped" — was | |
| # the same arithmetic #1226 disproved, and it stayed wrong here after the | |
| # summary was fixed: on a canary it logged "running 0 minus 0" while three | |
| # specs ran. What runs is logged once, below, after the verdict settles it. | |
| echo "Impacted specs resolved: $TOTAL ($DIRECT direct, $((TOTAL - DIRECT)) transitive); $DROPPED dropped by the cap" | |
| # A CI-ONLY diff imports into no spec, so the resolver above honestly | |
| # returns zero and this lane used to skip — proving the change PARSES, | |
| # never that it RUNS (#1159). PR #1157 rewired four workflows onto a new | |
| # composite action with every check green and the E2E lane `skipping`; | |
| # the action had executed nowhere at merge time, and a bad `uses:` path | |
| # would have surfaced first as the next daily failing in a step unrelated | |
| # to any spec. | |
| # | |
| # `ci-change-coverage.mjs` answers the other half: does the PR LANE run | |
| # what changed? It derives that from the YAML (a workflow's `scripts/x` | |
| # refs and its `uses: ./.github/actions/y`, plus each action's own refs), | |
| # so a new action wired in here is covered the day it lands. | |
| # canary → the PR lane's own wiring changed: run a 3-spec set so the | |
| # lane boots Langflow and walks pre-flight → health gate → | |
| # Playwright for real | |
| # dispatch → the surface belongs to another lane; name the workflows to | |
| # dispatch instead of implying coverage | |
| CANARY=false | |
| # Empty unless the classifier ran, so the renderer is only handed a file that | |
| # exists — `${CI_COVERAGE:+…}` then omits the flag entirely rather than passing | |
| # a path to nothing. | |
| CI_COVERAGE="" | |
| if [ "$TOTAL" -eq 0 ]; then | |
| node scripts/ci-change-coverage.mjs --stdin --format=json < /tmp/changed.txt > /tmp/ci-coverage.json \ | |
| || { echo "::error::CI-change classification failed — treating as undecidable rather than as 'no CI change'."; exit 1; } | |
| CI_COVERAGE=/tmp/ci-coverage.json | |
| VERDICT=$(jq -r .verdict /tmp/ci-coverage.json) | |
| jq -r '.reasons[] | " " + .' /tmp/ci-coverage.json | |
| case "$VERDICT" in | |
| canary) | |
| CANARY=true | |
| SPECS=$(jq -r '.canarySpecs | join(" ")' /tmp/ci-coverage.json) | |
| echo "::warning::CI-only change to a surface THIS lane runs ($(jq -r '.ciFiles | join(", ")' /tmp/ci-coverage.json)). No spec imports it, so the lane runs the canary instead of skipping (#1159)." | |
| ;; | |
| dispatch) | |
| echo "::warning::CI-only change to $(jq -r '.ciFiles | join(", ")' /tmp/ci-coverage.json), which THIS lane does not run — nothing here proves it works. Dispatch $(jq -r '.dispatchWorkflows | join(", ")' /tmp/ci-coverage.json) on this branch before merging (#1159)." | |
| ;; | |
| esac | |
| fi | |
| echo "Running: ${SPECS:-<none>}" | |
| # The SUMMARY is not written here. It is rendered in one pass at the end of | |
| # this step by `scripts/render-impacted-summary.mjs`, because the run count is | |
| # only final after the provider verdict below and because everything that | |
| # qualifies it must print AFTER it, not above it (#1226). The `::warning::` | |
| # annotations stay inline: they are log-level, unaffected by ordering, and | |
| # they are what keeps a shortened run from reading as a clean one even if the | |
| # step dies before it can render (#1012). | |
| if [ "$FULL_SUITE" = "true" ]; then | |
| echo "::warning::This PR changes a suite-wide surface (fixtures / playwright.config / global hooks): ALL $TOTAL specs are impacted. PR CI runs a bounded subset — dispatch manual.yml on this branch for the full suite before merging." | |
| fi | |
| if [ "$DROPPED" -gt 0 ]; then | |
| echo "::warning::Capped at $IMPACTED_SPEC_CAP of $TOTAL impacted specs — $DROPPED not run in this PR (listed in the step log)." | |
| fi | |
| # `has_specs` and `specs` are emitted at the END of this step, after the | |
| # provider-coverage verdict below may have removed specs from the run | |
| # list. Emitting them here as well would write the key twice and leave | |
| # which value wins up to the runner (#1216). | |
| { | |
| echo "full_suite=$FULL_SUITE" | |
| echo "impacted_total=$TOTAL" | |
| echo "dropped_count=$DROPPED" | |
| } >> "$GITHUB_OUTPUT" | |
| # Decide (a) whether the impacted specs need the Collect models sweep and | |
| # (b) whether any of them would otherwise run WITHOUT a provider they | |
| # need. The second question used to resolve silently to "run it anyway", | |
| # which is how PR #1152 — a one-helper change — produced a red in | |
| # `agent-component-regression.spec.ts` (`value-dropdown-model_model` never | |
| # renders with no provider configured) that had nothing to do with its | |
| # diff. That spec is `@stable` and green in the daily, where the sweep runs. | |
| # | |
| # The verdict turns on what the PR is ABOUT, because forcing the sweep for | |
| # every provider-dependent spec would re-couple unrelated PRs to provider | |
| # key health (`Collect models` is a HARD gate here) — the #915/#910/#911 | |
| # cost this gate exists to avoid: | |
| # - provider-dependent AND changed directly ⇒ sweep, and run it; | |
| # - provider-dependent only transitively ⇒ EXCLUDE it, and say so. | |
| # A spec that CONSUMES the sweep's output still forces it either way; that | |
| # asymmetry is deliberate and argued in the script's header. | |
| # | |
| # A canary still forces the sweep (#1159), for the health gate below. | |
| # Logic and wording live in the script so `npm run test:scripts` covers | |
| # them; a verdict it cannot produce exits 2 and fails this step rather | |
| # than degrading to "LLM-free" (#1012). | |
| CANARY_FLAG="" | |
| if [ "$CANARY" = "true" ]; then CANARY_FLAG="--canary"; fi | |
| node scripts/provider-dependent-specs.mjs --stdin --format=json \ | |
| --changed-file=/tmp/changed.txt $CANARY_FLAG \ | |
| < /tmp/impacted.json > /tmp/provider.json \ | |
| || { | |
| echo "::error::provider-coverage verdict failed" | |
| # Render what CAN still be established (resolution, suite-wide, the cap) | |
| # and name the gap, so an aborted verdict leaves an explained summary | |
| # instead of an empty one. `|| true`: this path is already failing, and a | |
| # renderer error must not replace the real cause (#1226). | |
| rm -f /tmp/provider.json | |
| node scripts/render-impacted-summary.mjs --impacted=/tmp/impacted.json \ | |
| --specs="$SPECS" --cap="$IMPACTED_SPEC_CAP" $CANARY_FLAG \ | |
| >> "$GITHUB_STEP_SUMMARY" || true | |
| exit 1 | |
| } | |
| NEEDS_MODELS=$(jq -r '.needsModels' /tmp/provider.json) | |
| # Re-derive the run list from the verdict, so an excluded spec cannot reach | |
| # Playwright — but NEVER on a canary run. A canary's specs come from | |
| # `ci-change-coverage.mjs`, not from the import graph, so `impacted.selected` | |
| # is empty there and overwriting `$SPECS` with the verdict's (empty) run list | |
| # would blank it, trip `has_specs=false`, and skip the whole E2E job — taking | |
| # `Collect models` and the health gate with it, since both live in that job. | |
| # That is precisely the "green check, nothing ran" hole #1159 exists to close. | |
| if [ "$CANARY" != "true" ]; then | |
| SPECS=$(jq -r '.run | join(" ")' /tmp/provider.json) | |
| fi | |
| # Log-level annotations only; the summary bullets for these are the renderer's | |
| # job. Kept inline because a `::warning::` is what still tells a reviewer the | |
| # run was shortened even if this step dies before rendering (#1012). | |
| if [ "$(jq -r '.excluded | length' /tmp/provider.json)" -gt 0 ]; then | |
| echo "::warning::$(jq -r '.warning' /tmp/provider.json)" | |
| fi | |
| if [ "$(jq -r '.forcedToAvoidEmptyRun' /tmp/provider.json)" = "true" ]; then | |
| echo "::warning::Every impacted spec needs a provider, so the sweep runs rather than leaving this lane with nothing to execute — decoupling from key health is not worth ALL of a PR's coverage (#1226)." | |
| fi | |
| echo "Needs provider models: $NEEDS_MODELS" | |
| echo "Running after the provider verdict: ${SPECS:-<none>}" | |
| # The whole summary, rendered once, from the run list this step is about to | |
| # export (#1226). Nothing about WHAT it says is decided here: the counts, the | |
| # order, and every caveat live in `render-impacted-summary.mjs`, where they are | |
| # asserted on OUTPUT by `npm run test:scripts`. Three wrong figures shipped | |
| # from this block while it was inline shell, each one guarded only by a regex | |
| # over this file — and each of those regexes was then shown to miss its own | |
| # mutation. Same reason the verdict above is a script. | |
| node scripts/render-impacted-summary.mjs \ | |
| --impacted=/tmp/impacted.json --provider=/tmp/provider.json \ | |
| --specs="$SPECS" --cap="$IMPACTED_SPEC_CAP" $CANARY_FLAG \ | |
| ${CI_COVERAGE:+--ci-coverage=$CI_COVERAGE} \ | |
| >> "$GITHUB_STEP_SUMMARY" \ | |
| || { echo "::error::could not render the run summary"; exit 1; } | |
| # The run list is whatever survived the verdict — emitted here and only | |
| # here. An empty list skips the E2E job through `has_specs`, which is the | |
| # only safe outcome: `npx playwright test` with no paths runs the whole | |
| # suite. The ::warning:: above is what keeps that skip from reading as a | |
| # pass (#1012). | |
| HAS_SPECS=false | |
| if [ -n "$SPECS" ]; then HAS_SPECS=true; fi | |
| { | |
| echo "has_specs=$HAS_SPECS" | |
| echo "specs=$SPECS" | |
| echo "needs_models=$NEEDS_MODELS" | |
| echo "canary=$CANARY" | |
| } >> "$GITHUB_OUTPUT" | |
| e2e: | |
| name: Run impacted E2E specs | |
| needs: detect-specs | |
| if: needs.detect-specs.outputs.has_specs == 'true' && github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| services: | |
| langflow: | |
| image: langflowai/langflow-nightly:latest | |
| ports: | |
| - 7860:7860 | |
| env: | |
| LANGFLOW_AUTO_LOGIN: "true" | |
| LANGFLOW_SUPERUSER: langflow | |
| LANGFLOW_SUPERUSER_PASSWORD: langflow123 | |
| # Cap the backend to one worker. Langflow's image default is (2*cpu)+1 | |
| # workers, each holding full in-memory state; under the collect-models | |
| # load on the runner they contend until requests hang, and the | |
| # impacted-specs preflight then times out on /api/v1/version after | |
| # collect-models succeeds (#922, blocked #867). The launch scripts | |
| # already default LANGFLOW_WORKERS=1 for this reason (#773) — the | |
| # service container was the one place missing it. | |
| LANGFLOW_WORKERS: "1" | |
| # Cap how long ONE wedge can cost (#1048). Default is 300 s | |
| # (`worker_timeout` in lfx runtime settings → gunicorn's `timeout`). | |
| # LangflowUvicornWorker is ASYNC, so this is a heartbeat watchdog on the | |
| # event loop, not a request deadline — build duration cannot trip it, and | |
| # Langflow's published docs describe it wrongly. Mechanism, the docs | |
| # caveat, and the startup measurement: see daily-stable.yml. | |
| # | |
| # This job is where the cost was measured. Run 30410211167 (PR #1042) | |
| # burned two full 300 s outages: the post-collect-models wedge the health | |
| # gate below had to wait out (227 s, cleared only by gunicorn's kill), | |
| # and a second block starting within seconds of a failing Google call. | |
| # Four worker kill/restart cycles in 26 min, and the collateral landed on | |
| # specs that make no LLM call at all (general-bugs-shard-3909 timing out | |
| # on `add-project-button`). A wedged worker never self-heals, so waiting | |
| # 300 s buys nothing; at 120 the kill lands 60-120 s after the stall | |
| # instead of 150-300 s. | |
| # | |
| # This BOUNDS the damage, it does not prevent it: a spec whose API call | |
| # has a 20 s timeout still dies inside that window. Eliminating | |
| # collateral needs the provider-heavy specs not to share the backend with | |
| # unrelated ones (serialization / low-concurrency lane) — see #1048. | |
| # Rollback is this one value. | |
| LANGFLOW_WORKER_TIMEOUT: "120" | |
| # Nightly image defaults LANGFLOW_ALLOW_CUSTOM_COMPONENTS=false | |
| # (custom-component creation disabled → sidebar button hidden, API | |
| # 403). Enable it so the custom-component specs exercise the feature. | |
| LANGFLOW_ALLOW_CUSTOM_COMPONENTS: "true" | |
| # A2A is off by default (lfx `a2a_enabled=False`) and its router is ALWAYS | |
| # mounted, so with the flag off the three /api/v1/a2a/* routes answer 404 — | |
| # indistinguishable from "not mounted". Enable it so the | |
| # core-functionality/a2a specs exercise the surface instead of asserting | |
| # against a disabled one, which would pass while testing nothing (#1240; | |
| # surface scoped in #1195). | |
| LANGFLOW_A2A_ENABLED: "true" | |
| # Tracing is OFF by default (cuts startup time / backend noise for the | |
| # majority of specs). It is turned ON for two independent reasons, and | |
| # the second one is why this is no longer a single condition (#1300): | |
| # | |
| # 1. The observability/traces specs need the SUT to emit traces + | |
| # spans, and without it their setup times out. | |
| # 2. Langflow's traces are the ONLY place this lane's token spend can | |
| # be read (the recorder below polls /api/v1/monitor/traces, #1197), | |
| # and a flow's trace 404s the moment the flow is deleted. | |
| # | |
| # Reason 2 was missing, and it made #1210's recorder on this lane | |
| # structurally unable to see anything: EVERY run that produced a token | |
| # artifact after #1210 merged recorded `0 trace(s)` — all 35 of them, | |
| # 30855127426 through 31021593309, each carrying a `token-attrib.jsonl` | |
| # and no `token-probes.jsonl` — because tracing was off on every one. | |
| # Run 31018914069 is the clean measurement: 19 `llm-agents` specs plus | |
| # collect-models, a provider configured (`needs_models: true`, | |
| # `Collect models` ran), 59 passed / 29 skipped, and an artifact | |
| # carrying 139 attribution COST records and not one trace. The | |
| # summarizer's own honest-zero message names this flag first among the | |
| # candidate causes, and it was right. | |
| # | |
| # `needs_models` is the lane's own verdict for "this run will execute at | |
| # least one provider-dependent spec" (scripts/provider-dependent-specs.mjs, | |
| # #1216) — the closest thing the lane has to "this run has spend to | |
| # measure", and not the same statement. Two known gaps, neither worth a | |
| # second mechanism: `@playground` is deliberately not a provider tag | |
| # there, and the provider keys are in the Playwright step's env on every | |
| # run, so a spec that installs its own credential and pins a model would | |
| # spend without setting the verdict. Gating on it rather than switching | |
| # tracing on unconditionally keeps the cheap path for the import-graph | |
| # majority of PRs, while the daily and manual, which measure every run, | |
| # keep tracing on unconditionally (daily-stable.yml has been hardcoded | |
| # `"false"` since #459, before the token monitor existed — the previous | |
| # comment here misattributed that to #1197). | |
| # | |
| # What enabling it COSTS. The "cuts startup time / backend noise" | |
| # rationale above is what is being traded away, so be exact about what | |
| # is and is not known. | |
| # | |
| # NOT measured: the startup time, and the backend noise. Nothing in | |
| # #1300 measures either, and this comment does not claim to. | |
| # | |
| # Measured: daily-stable.yml has run with tracing on unconditionally | |
| # since #459 (2026-06-30), and the setting has never been touched since | |
| # (`git log -S`/`-G`, one commit), across 65 runs of the full `@stable` | |
| # sweep — 26 scheduled, 39 dispatched, 2 of them cancelled. So an LLM | |
| # run on this lane now boots in a configuration with ~5 weeks and 26 | |
| # scheduled runs behind it, not a novel one. That is an argument from | |
| # precedent. It is weaker than a measurement, and it is what exists. | |
| # | |
| # One attribution NOT to make, because an earlier version of this | |
| # comment made it and this file refutes it 60 lines above: the wedge is | |
| # not wholly a `Collect models` problem. CLAUDE.md does attribute | |
| # #922/#927 to that sweep, but the LANGFLOW_WORKER_TIMEOUT note above | |
| # records run 30410211167 burning TWO 300 s outages — the | |
| # post-collect-models wedge, AND "a second block starting within seconds | |
| # of a failing Google call", i.e. during the spec run itself. That | |
| # second class is exactly the class of run this change newly traces. It | |
| # is bounded by the 120 s worker timeout, not eliminated, and tracing | |
| # has not been shown to affect it either way. | |
| # | |
| # One class does NOT keep the cheap path, and it is the one to know | |
| # about: decideProviderCoverage() sets `needs_models=true` for a CANARY | |
| # run (a CI-only diff, three LLM-free specs), so those now boot with | |
| # tracing on and record another honest zero. That follows from reusing | |
| # the lane's own verdict instead of inventing a second one, and the | |
| # cost is a traced boot on a class of PR that is already paying for a | |
| # full Langflow + `Collect models` sweep. | |
| LANGFLOW_DEACTIVATE_TRACING: ${{ (contains(needs.detect-specs.outputs.specs, 'observability-monitoring') || needs.detect-specs.outputs.needs_models == 'true') && 'false' || 'true' }} | |
| # Let Langflow reach the sibling go-httpbin service below (#1128). Its | |
| # SSRF layer blocks private addresses unless pre-authorized, and the | |
| # API Request component's validators.url() rejects a single-label host | |
| # — so ECHO_BASE_URL is a raw container IP and the RFC-1918 ranges are | |
| # authorized by CIDR rather than by a fixed address the Docker network | |
| # assigns per run. Same set as daily-stable.yml (#462). | |
| LANGFLOW_SSRF_ALLOWED_HOSTS: "172.16.0.0/12,10.0.0.0/8,192.168.0.0/16" | |
| options: >- | |
| --health-cmd "curl -f http://localhost:7860/health_check || exit 1" | |
| --health-interval 15s | |
| --health-timeout 10s | |
| --health-retries 10 | |
| --health-start-period 90s | |
| # Self-hosted echo endpoint for the API Request and agent-fetch-tool specs. | |
| # Without it this lane calls public httpbin.org / postman-echo.com and reds | |
| # on their outages: PR #1133 lost three specs to an httpbin 504 while its | |
| # own diff touched none of them, and the log read like a product failure | |
| # (#1128; earlier recurrences #383/#407/#462/#639). | |
| # | |
| # This job runs on the RUNNER HOST, not in a container, so the service | |
| # alias is not resolvable here — `resolve-echo-endpoint` reads the | |
| # container IP with `docker inspect` instead, probes the published port, | |
| # and hands Langflow the IP. Tag has NO `v` prefix (#639). | |
| # No healthcheck: the scratch-based image ships no shell or curl, so the | |
| # action's poll is the healthcheck. | |
| go-httpbin: | |
| image: ghcr.io/mccutchen/go-httpbin:2.23.1 | |
| ports: | |
| - 8080:8080 | |
| # The token sidecar's bounds, at JOB level because TWO steps read them: the | |
| # poller below and the Playwright step, where the attribution sidecar runs | |
| # inside `deleteFlow`'s teardown hook. They used to live in the poller step's | |
| # own `env:`, which does not cross into another step -- so the sidecar never | |
| # saw either one and silently used its hard-coded defaults. The numbers | |
| # happened to match, so nothing was wrong in the run and nothing could be seen | |
| # in a diff; the only symptom was a knob that did not turn, precisely when a | |
| # wedged monitor endpoint made someone want to turn it. | |
| # | |
| # Defined ONCE, here, so the poller and the sidecar cannot end up bounded by | |
| # different numbers. Pinned by scripts/token-sidecar-knobs.test.mjs. | |
| env: | |
| TOKENS_TIMEOUT_MS: "8000" | |
| TOKENS_DETAIL_CAP: "25" | |
| # Per-CALL wall-clock ceiling for the attribution sidecar. Wired AHEAD of its | |
| # reader on purpose: the budget itself lands with #1217's `deleteFlow` hook, | |
| # and adding it from that PR would mean touching THIS file from a spec change, | |
| # which flips this lane's coverage verdict to `canary` and drops its | |
| # impacted-spec run. An unread variable costs nothing; a lane that cannot turn | |
| # the knob when a monitor endpoint wedges costs a run. With the two above it | |
| # bounds a call at budget + timeout = 23s instead of cap x timeout = 208s. | |
| # Read by the sidecar only; the poller has its own TOKENS_MAX_SECONDS. | |
| TOKENS_BUDGET_MS: "15000" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: "20" | |
| cache: "npm" | |
| - run: npm ci | |
| - name: Install Playwright browsers | |
| uses: ./.github/actions/setup-playwright | |
| # Before ANY spec runs, including collect-models: point the echo-dependent | |
| # specs at the sibling go-httpbin service instead of the public internet | |
| # (#1128). `mode: fail` is deliberate and is the one place this lane differs | |
| # from the daily: a PR is the lane a human is waiting on, so a silent | |
| # fallback to public httpbin.org — which is what reded PR #1133 and read as | |
| # a product failure — is worse than a named infra failure it can be | |
| # re-dispatched from. The daily keeps `warn`, where a day of coverage | |
| # outweighs strictness. | |
| - name: Resolve echo endpoint | |
| uses: ./.github/actions/resolve-echo-endpoint | |
| with: | |
| mode: fail | |
| in_container: "false" | |
| # Collect provider/model data BEFORE the impacted specs, mirroring | |
| # daily-stable.yml. Without this, `models.json` is absent (it is | |
| # git-ignored) and an agent/LLM spec's `resolveTestTargets` falls back to | |
| # picking a model straight from the live agent dropdown by name — without | |
| # validating that the key can access it. On some nightly builds that | |
| # resolves to a model the CI OPENAI_API_KEY's project cannot use (e.g. | |
| # `gpt-4.1-nano`), so the Agent build 403s and the spec dies on setup, | |
| # unrelated to the PR (#873). `collect-models` probes each key with a real | |
| # ~1-token completion and writes only ACCESSIBLE models, so agent specs | |
| # resolve a usable model — matching what the daily validates (no | |
| # divergence). Same provider keys as daily-stable.yml. | |
| # Skipped when no impacted spec needs provider model data (see detect-specs | |
| # → needs_models). Keeps a provider billing/quota outage from reddening a | |
| # PR whose specs never use that provider (#915/#910/#911 class). | |
| - name: Collect models | |
| if: needs.detect-specs.outputs.needs_models == 'true' | |
| # Fatal on a normal LLM PR (a spec that needs models must not run without | |
| # them), NON-fatal on a canary run: there the sweep exists only so the | |
| # health gate below has something to gate, and nothing in the canary | |
| # consumes a provider key — so a drained key must not block a CI-only PR | |
| # (#1159). Same trade the daily makes for the same reason (#980). | |
| continue-on-error: ${{ needs.detect-specs.outputs.canary == 'true' }} | |
| env: | |
| CI: "true" | |
| # This run is what IMPORTS the provider credentials into Langflow, so | |
| # the pre-flight credential check (globalSetup, #884) must not fire | |
| # here — it would fail on the very keys this step is about to set | |
| # (chicken-and-egg). Matches daily-stable.yml's Collect models step. | |
| PREFLIGHT_SKIP_CREDENTIALS: "1" | |
| # NO RETRIES for this step (#1011, ported here by #1019). CI's default | |
| # is 2, so a FAILING collect-models runs three full attempts, each | |
| # re-importing every key and re-walking the Model Providers UI against | |
| # the single backend this job shares with the specs. On the daily that | |
| # multiplier turned a ~49 s step into 7-12 min of sustained load and | |
| # wedged the gunicorn worker. | |
| # | |
| # The trade is NOT the daily's, and the difference is worth stating. | |
| # There the step is `continue-on-error: true` (#980): its result is | |
| # purely diagnostic, so dropping the retries costs nothing and spares | |
| # the shard that still has to run. Here the step is a HARD gate — a red | |
| # sweep fails the job outright, the specs never run, and the health gate | |
| # below is skipped for want of `always()`. So the cap protects no | |
| # downstream step; what it buys is a red in ~1 min instead of three | |
| # sweeps' worth of load and runner minutes. What it COSTS is the retry | |
| # that could have rescued a transient sweep failure (a provider 503, a | |
| # UI timing flake) — a real, if narrow, loss of PR-check reliability. | |
| # Accepted deliberately: a sweep that fails for a substantive reason | |
| # (drained key, component missing from the image — #1039) fails all | |
| # three attempts too. Revisit if PRs start going red here on flakes a | |
| # retry would have absorbed. | |
| # | |
| # Note the cap alone would NOT have prevented the wedges of 2026-07-28 | |
| # (three occurrences, all after a collect-models that PASSED on its | |
| # first attempt in ~1 min). One successful sweep is enough to wedge the | |
| # backend; the health gate below is what covers that case. | |
| PLAYWRIGHT_RETRIES: "0" | |
| PLAYWRIGHT_BASE_URL: "http://localhost:7860/" | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} | |
| MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} | |
| run: npx playwright test tests/collect-models.spec.ts --reporter=line | |
| # Health gate between the two steps that share the Langflow container — | |
| # ported from daily-stable.yml (#1011) by #1019. collect-models' model-toggle | |
| # sweep can leave the backend process-wide WEDGED: container alive, event | |
| # loop blocked, requests simply not answered (#922, #927). globalSetup polls | |
| # for 120 s and then throws, so a wedge that outlasts that window costs the | |
| # whole job and reports itself as a preflight timeout on /api/v1/version with | |
| # no mention of the cause. | |
| # | |
| # On the daily this gate was documented as "attribution, with recovery as | |
| # upside" because no run had ever shown the backend coming back. Run | |
| # 30405897916 (job 90432861222) measured it — a pr-validation run that had | |
| # NO gate yet, so what stood in for one was globalSetup's own 120 s poll. | |
| # From the service container's log: | |
| # | |
| # 23:01:09 Collect models → 1 passed (57.2s) | |
| # 23:01:18 globalSetup starts polling /api/v1/version | |
| # 23:03:18 globalSetup gives up (HEALTH_TIMEOUT_MS = 120000) → job RED | |
| # 23:04:39 critical WORKER TIMEOUT (pid:36) [gunicorn.error] | |
| # 23:04:40 error Worker (pid:36) was sent SIGKILL! | |
| # 23:04:51 replacement worker finishes startup ("Initializing agentic | |
| # global variables…"), serves /auto_login at 23:04:52 | |
| # ← 214 s after polling began | |
| # | |
| # Read that last line for exactly what it is: recovery inferred from the | |
| # replacement worker's startup log, not from an observed 200 on | |
| # /api/v1/version — by then nothing was polling, the job had already died. | |
| # It is enough to establish that the backend came back, and that this step's | |
| # budget would have found it where globalSetup's 120 s missed by 94 s. So | |
| # here the gate is a RECOVERY mechanism, not only a diagnostic one — on one | |
| # measurement. The same log explains why the service-level --health-cmd | |
| # cannot cover this: gunicorn itself only noticed ~3.5 min in, and the | |
| # container reads healthy that whole time. | |
| # | |
| # Shared implementation since #1045 (`.github/actions/wait-for-backend`) — | |
| # the deadline, the heartbeat and the curl-exit-code decoding this copy | |
| # pioneered now apply to the daily and manual lanes too, and weekly-stable | |
| # finally has a gate at all. Runs only when Collect models ran: with no sweep | |
| # there is no wedge to wait out, an unrelated startup failure is | |
| # globalSetup's to report, and an LLM-free PR must not be billed for the wait. | |
| - name: Wait for the backend to recover from the collect-models load | |
| if: needs.detect-specs.outputs.needs_models == 'true' | |
| uses: ./.github/actions/wait-for-backend | |
| with: | |
| # Deadline comes from the action's default (420 s) — this lane is where | |
| # that number was argued for and it is now every lane's. | |
| next_step_label: "the impacted-specs run" | |
| attribution: "NOT a failure of the specs this PR touches" | |
| # No collect_models_outcome: unlike the daily and manual lanes, the step | |
| # here is a HARD gate (not `continue-on-error`), so a failed sweep has | |
| # already reddened the job and there is no surviving run to warn about. | |
| # Pin this lane to ONE provider's settled model (#1169). resolveTestTargets() | |
| # parametrizes over one model per active provider, so every impacted agent | |
| # spec runs an openai AND an anthropic AND a google variant here. That is | |
| # daily-stable.yml's job — it is the lane that owes multi-provider coverage. | |
| # This lane answers "does this PR break the specs it touches", which one | |
| # provider settles, and the difference is not cosmetic: measured 2026-07-31, | |
| # this workflow ran 141 times in 3.5 days, each model-needing run paying an | |
| # anthropic variant on claude-sonnet-5 ($3/$15 per MTok) for assertions | |
| # gpt-4o-mini ($0.15/$0.60) satisfies identically — 20-25x per token, with no | |
| # prompt caching on the anthropic side. Both the CI secret and the local key | |
| # drained inside that window (Anthropic credit is account-scoped, so they | |
| # share one balance). | |
| # | |
| # Why a script (see its header for the full argument): MODEL_TEST_PROVIDER | |
| # alone does not narrow the run, it runs the provider's ENTIRE catalog (the | |
| # dedup branch is skipped) — so the two variables must be emitted as a pair, | |
| # which this does. And the model must be the one collect-models settled on: | |
| # a hardcoded id skips silently the day the CI project loses access, leaving | |
| # a green PR that tested nothing (#570/#1012). | |
| # | |
| # Declines to pin — with a ::warning:: — when the provider is not active, so | |
| # a drained openai key costs a costlier multi-provider run rather than zero | |
| # coverage. Gated on needs_models for the same reason as the two steps above: | |
| # with no sweep there is no settled model to read. | |
| - name: Pin the lane to a single provider's settled model | |
| if: needs.detect-specs.outputs.needs_models == 'true' | |
| run: node scripts/select-pr-model-target.mjs --provider openai | |
| # In-run token consumption recorder, extended to this lane (#1183 — measure | |
| # the suite's real LLM spend per lane/provider). Same mechanism as | |
| # daily-stable.yml (#1197): poll /api/v1/monitor/traces and price what each | |
| # trace spent, because deleting a flow 404s its trace and this suite deletes | |
| # every flow it creates, so the only place to read the data is DURING the run. | |
| # | |
| # Placed here — after Collect models / the health gate / the model pin, | |
| # right before the actual Playwright step — so it only ever runs where | |
| # specs actually run: the job itself is already gated on | |
| # `needs.detect-specs.outputs.has_specs == 'true'`, and this step carries | |
| # no separate condition of its own, same as "Run impacted specs" below (an | |
| # earlier hard failure in this job skips both identically). | |
| # | |
| # MEASUREMENT-ONLY on this lane: --summarize (below, after the run) still | |
| # renders a step-summary table, but TOKENS_SUPPRESS_HISTORY keeps it from | |
| # writing reports/token-history.jsonl — that file is the daily's series, | |
| # one line per FULL @stable sweep, and this lane's scope is whatever | |
| # subset of specs the import graph selected for THIS PR (capped at | |
| # IMPACTED_SPEC_CAP, frequently zero LLM specs at all). Mixing the two | |
| # would corrupt the trend and the anomaly baseline the daily itself relies | |
| # on (see the summarizer's own comment on the knob). | |
| # | |
| # DIAGNOSTIC ONLY: continue-on-error, and it never gates the run. | |
| - name: Start the token consumption recorder | |
| shell: bash | |
| continue-on-error: true | |
| run: | | |
| nohup node scripts/watch-tokens.mjs > /tmp/tokens.log 2>&1 & | |
| echo "$!" > /tmp/tokens.pid | |
| disown | |
| echo "Token recorder started (pid $(cat /tmp/tokens.pid))." | |
| env: | |
| TOKENS_BASE_URL: http://localhost:7860 | |
| TOKENS_OUT: token-probes.jsonl | |
| TOKENS_INTERVAL_MS: "15000" | |
| # TOKENS_TIMEOUT_MS and TOKENS_DETAIL_CAP are deliberately NOT here: they | |
| # are defined once at job level, because the attribution sidecar in the | |
| # Playwright step reads them too and a step-level env: does not reach it. | |
| # Below the job's timeout-minutes: 60 so this is a real backstop, not | |
| # dead configuration reached only after the runner already killed the | |
| # job. Same reasoning as the daily's TOKENS_MAX_SECONDS. | |
| TOKENS_MAX_SECONDS: "3300" | |
| - name: Run impacted specs | |
| env: | |
| CI: "true" | |
| # When "Collect models" is skipped (needs_models == false) the provider | |
| # keys below are still in the environment but were never imported into | |
| # Langflow as global variables, so globalSetup's credential pre-flight | |
| # (#884) hard-fails in CI and the job dies before running a single | |
| # spec — every LLM-free spec PR was guaranteed red after #953 added the | |
| # skip. Nothing in an LLM-free run consumes those credentials, so the | |
| # check has nothing to protect there: skip it exactly when the import | |
| # step was skipped, and keep it enforced for LLM runs. | |
| # | |
| # A canary run is the third case (#1159): the sweep DID run, but only to | |
| # exercise the health gate, and it is allowed to fail. Enforcing the | |
| # credential pre-flight there would let a drained provider key kill a | |
| # CI-only PR through the back door — the canary specs make no LLM call at | |
| # all, so there is nothing for the check to protect. | |
| PREFLIGHT_SKIP_CREDENTIALS: ${{ needs.detect-specs.outputs.needs_models == 'true' && needs.detect-specs.outputs.canary != 'true' && '0' || '1' }} | |
| PLAYWRIGHT_BASE_URL: "http://localhost:7860/" | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} | |
| MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} | |
| SPECS: ${{ needs.detect-specs.outputs.specs }} | |
| # Turns the cleanup sidecar on for this lane (#1183), same as | |
| # daily-stable.yml: with this unset (local runs) the helper makes no | |
| # request and writes no file. | |
| TOKENS_ATTRIB: token-attrib.jsonl | |
| run: | | |
| echo "Running specs: $SPECS" | |
| # shellcheck disable=SC2086 | |
| npx playwright test $SPECS --reporter=github | |
| # Destructive lane (#1010). `@destructive` tests wipe account-wide state, so | |
| # playwright.config.ts keeps them out of the run above and pins this lane to | |
| # workers=1. Scoped to the same impacted $SPECS, so it is a no-op (via | |
| # --pass-with-no-tests) unless a destructive spec is actually impacted. | |
| # --reporter=github replaces the reporter list, so no HTML is written here and | |
| # the main run's playwright-report/ stays intact. | |
| - name: Run destructive lane | |
| if: always() | |
| env: | |
| CI: "true" | |
| PW_DESTRUCTIVE: "1" | |
| PREFLIGHT_SKIP_CREDENTIALS: ${{ needs.detect-specs.outputs.needs_models == 'true' && '0' || '1' }} | |
| PLAYWRIGHT_BASE_URL: "http://localhost:7860/" | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} | |
| MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} | |
| SPECS: ${{ needs.detect-specs.outputs.specs }} | |
| run: | | |
| # shellcheck disable=SC2086 | |
| npx playwright test $SPECS --grep "@destructive" --pass-with-no-tests \ | |
| --reporter=github --output=test-results-destructive | |
| # Stop the recorder, price what it saw, and upload the raw JSONL — mirrors | |
| # daily-stable.yml's shard-level "Stop and collect" + merge-job "Summarize" | |
| # steps, collapsed into one job since this lane has no shards to merge. | |
| # always(): the recorder's data is most useful precisely when the run went | |
| # red, and continue-on-error on every step here means a defect in the | |
| # reporting itself can never touch the specs' own pass/fail. | |
| - name: Stop and collect token consumption | |
| if: always() | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| # SIGTERM is the recorder's normal exit path: it stops after the | |
| # current probe. | |
| if [ -f /tmp/tokens.pid ]; then | |
| kill "$(cat /tmp/tokens.pid)" 2>/dev/null || true | |
| fi | |
| # Let an in-flight append land before the file is copied — same | |
| # reasoning and margin as the daily's own copy of this step. | |
| sleep 10 | |
| mkdir -p tokens | |
| cp token-probes.jsonl tokens/ 2>/dev/null || true | |
| cp token-attrib.jsonl tokens/ 2>/dev/null || true | |
| echo "--- token recorder stdout (tail) ---" | |
| tail -n 5 /tmp/tokens.log 2>/dev/null || true | |
| - name: Summarize token consumption | |
| if: always() | |
| continue-on-error: true | |
| run: node scripts/watch-tokens.mjs --summarize | |
| env: | |
| TOKENS_DIR: tokens | |
| TOKENS_PRICES: scripts/lib/model-prices.json | |
| WORKFLOW: pr-validation | |
| LANGFLOW_IMAGE: "langflowai/langflow-nightly:latest" | |
| # Measurement-only lane (#1183): never add a line to | |
| # reports/token-history.jsonl. See "Start the token consumption | |
| # recorder" above and the summarizer's own comment on this knob for | |
| # why a capped, per-PR subset is not comparable to the daily's series. | |
| # Suppressed openly, not silently: the summarizer states this in both | |
| # its log line and the step summary itself (#1012's rule). | |
| TOKENS_SUPPRESS_HISTORY: "1" | |
| - name: Upload token consumption | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| continue-on-error: true | |
| with: | |
| name: tokens-pr-${{ github.run_id }} | |
| path: tokens/ | |
| if-no-files-found: ignore | |
| - name: Upload Playwright report | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: playwright-report-pr-${{ github.run_id }} | |
| path: playwright-report/ | |
| retention-days: 14 | |
| - name: Upload test results on failure | |
| uses: actions/upload-artifact@v7 | |
| if: failure() | |
| with: | |
| name: test-results-pr-${{ github.run_id }} | |
| path: test-results/ | |
| retention-days: 7 |