771 lines
39 KiB
YAML
771 lines
39 KiB
YAML
name: "Showcase: Validate"
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- "showcase/**"
|
|
- "examples/integrations/**"
|
|
- "package.json"
|
|
- "pnpm-lock.yaml"
|
|
- "pnpm-workspace.yaml"
|
|
- ".github/workflows/showcase_validate.yml"
|
|
- ".github/workflows/showcase_deploy.yml"
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- "showcase/**"
|
|
- "examples/integrations/**"
|
|
- "package.json"
|
|
- "pnpm-lock.yaml"
|
|
- "pnpm-workspace.yaml"
|
|
- ".github/workflows/showcase_validate.yml"
|
|
- ".github/workflows/showcase_deploy.yml"
|
|
|
|
# Least-privilege by default. Individual jobs/steps can widen when needed.
|
|
permissions:
|
|
contents: read
|
|
|
|
# Split concurrency per event so main-branch push runs are never canceled
|
|
# mid-execution (we need Slack failure alerts to fire reliably). PR runs
|
|
# still cancel in progress to keep PR CI responsive.
|
|
concurrency:
|
|
group: showcase-validate-${{ github.ref }}-${{ github.event_name }}
|
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
|
|
jobs:
|
|
validate:
|
|
name: Validate Showcase
|
|
# Hoist the Slack webhook into an env var so step-level `if:`
|
|
# expressions can reference it — `secrets.*` is not a valid
|
|
# named-value inside `if:` and causes a workflow startup failure
|
|
# on push events.
|
|
env:
|
|
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
|
# Depot (Startup plan, unlimited minutes) for persistent pnpm/npm
|
|
# cache across runs — cold ubuntu-latest runs were ~18-20m; Depot
|
|
# typically reduces to ~5-8m. 25m timeout retained as headroom.
|
|
runs-on: depot-ubuntu-24.04-4
|
|
timeout-minutes: 25
|
|
outputs:
|
|
inline_slack_notifier_reached: ${{ steps.inline_slack_marker.outputs.reached }}
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
|
id-token: write
|
|
defaults:
|
|
run:
|
|
# Pin shell so `set -euo pipefail` + `mapfile` behave the same
|
|
# across any future runner image changes (default on ubuntu is
|
|
# already bash, but we lock it explicitly).
|
|
shell: bash
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: 22
|
|
# Cache npm for the showcase/shell `npm ci` step below (shell is
|
|
# NOT a pnpm workspace member; it ships its own package-lock.json).
|
|
cache: "npm"
|
|
cache-dependency-path: showcase/shell/package-lock.json
|
|
|
|
- name: Setup pnpm
|
|
# Pinned to a specific minor rather than floating @v4 so that a
|
|
# silent upstream major/minor change can't alter install semantics
|
|
# on a random CI run. Bump deliberately when refreshing the toolchain.
|
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
|
|
|
- name: Verify lockfile is up to date
|
|
run: pnpm install --frozen-lockfile --ignore-scripts
|
|
|
|
- name: Enforce e2e spec count (baseline per package)
|
|
run: |
|
|
set -euo pipefail
|
|
shopt -s nullglob
|
|
# Single source of truth: showcase/scripts/fail-baseline.json
|
|
# `baselineDemoCount` is read here AND by validate-parity.ts so the
|
|
# per-package e2e-spec-count floor cannot drift between CI and the
|
|
# validator. If parsing fails we distinguish JSON syntax errors
|
|
# from schema failures (missing/non-integer/negative field).
|
|
set +e
|
|
MIN=$(node -e "
|
|
let v;
|
|
try {
|
|
v = require('./showcase/scripts/fail-baseline.json');
|
|
} catch (e) {
|
|
console.error('fail-baseline.json: JSON syntax error: ' + e.message);
|
|
process.exit(2);
|
|
}
|
|
const n = v.baselineDemoCount;
|
|
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) {
|
|
console.error('fail-baseline.json: schema failure: baselineDemoCount must be a non-negative integer');
|
|
process.exit(3);
|
|
}
|
|
console.log(n);
|
|
")
|
|
rc=$?
|
|
set -e
|
|
if [ "$rc" -ne 0 ]; then
|
|
# Preserve node's distinct rc (2=JSON syntax, 3=schema) in the
|
|
# annotation so the CI log pinpoints the cause without re-running.
|
|
echo "::error::Failed to read baselineDemoCount from showcase/scripts/fail-baseline.json (node exit=$rc; 2=JSON syntax, 3=schema)"
|
|
exit "$rc"
|
|
fi
|
|
failed=0
|
|
found=0
|
|
for pkg_dir in showcase/integrations/*/; do
|
|
[ -d "$pkg_dir" ] || continue
|
|
pkg=$(basename "$pkg_dir")
|
|
# Skip manifest-only packages (no src/ directory) — these are
|
|
# virtual/meta integrations (e.g. built-in-agent) that carry no
|
|
# source code or demos and therefore have no e2e specs to enforce.
|
|
if [ ! -d "${pkg_dir}src" ]; then
|
|
echo "skip: $pkg (manifest-only, no src/)"
|
|
continue
|
|
fi
|
|
found=$((found + 1))
|
|
e2e_dir="${pkg_dir}tests/e2e/"
|
|
if [ ! -d "$e2e_dir" ]; then
|
|
echo "::error file=$pkg_dir::Package '$pkg' is missing tests/e2e/ directory (required for baseline e2e coverage)"
|
|
failed=1
|
|
continue
|
|
fi
|
|
# Capture `find` output into a variable first so we can check
|
|
# its exit status directly. Bash process substitution (used with
|
|
# `mapfile < <(cmd)`) does NOT propagate the producer's exit
|
|
# status to the parent shell — `mapfile` only reports its own
|
|
# usage errors — so a failing `find` (EACCES on a subdir, ELOOP,
|
|
# transient I/O) would have been silently treated as "zero
|
|
# specs" and surfaced as the misleading "minimum required"
|
|
# error instead of the real root cause. Command substitution
|
|
# propagates `find`'s status via `$?` on the assignment, which
|
|
# we check immediately. A zero-spec result is a legitimate
|
|
# success from `find` and is handled by the `$count -lt $MIN`
|
|
# check below, not treated as a find failure.
|
|
# Aggregate find failures with the rest of the per-package
|
|
# failure modes (missing tests/e2e/, below-MIN count) so one bad
|
|
# package doesn't short-circuit reporting for the others. A
|
|
# single CI run should surface every problematic package at
|
|
# once; `exit "$failed"` at the end of the loop reports the
|
|
# aggregate.
|
|
if ! find_out=$(find "$e2e_dir" -maxdepth 1 -type f -name '*.spec.ts'); then
|
|
echo "::error file=$e2e_dir::find failed while enumerating specs for '$pkg'"
|
|
failed=1
|
|
continue
|
|
fi
|
|
specs=()
|
|
# Only populate the array if `find` produced output; `mapfile
|
|
# <<< ""` would otherwise create a single empty element and
|
|
# inflate the count by one.
|
|
if [ -n "$find_out" ]; then
|
|
mapfile -t specs <<< "$find_out"
|
|
fi
|
|
count=${#specs[@]}
|
|
if [ "$count" -lt "$MIN" ]; then
|
|
echo "::error file=$e2e_dir::Package '$pkg' has $count e2e spec(s); minimum required is $MIN"
|
|
failed=1
|
|
else
|
|
echo "ok: $pkg has $count spec(s)"
|
|
fi
|
|
done
|
|
if [ "$found" -eq 0 ]; then
|
|
echo "::error::No showcase/integrations/*/ directories found — baseline check cannot run"
|
|
exit 1
|
|
fi
|
|
exit "$failed"
|
|
|
|
- name: Run validate-parity (MUST checks gating)
|
|
working-directory: showcase/scripts
|
|
# MUST failures (missing manifest, missing src/app/demos dir) exit 1 and
|
|
# fail the PR. SHOULD deviations print warnings and exit 0. See
|
|
# showcase/scripts/validate-parity.ts for the full policy.
|
|
#
|
|
# `pnpm exec` resolves tsx from the pnpm-lock.yaml-pinned workspace
|
|
# install; `npx tsx` could fetch a drifting version on a registry
|
|
# cache miss.
|
|
run: pnpm exec tsx validate-parity.ts
|
|
|
|
- name: Run validate-fixture-tool-surface (aimock drift)
|
|
working-directory: showcase/scripts
|
|
# Cross-references every aimock fixture's returned tool-call names
|
|
# against the tool surface of each demo whose suggestion prompt
|
|
# contains the fixture's match substring. Catches the class of
|
|
# drift that caused the 2026-04-22 regression where generic
|
|
# substring matches (e.g. "pie chart") cross-fired across demos
|
|
# with different tool surfaces, leaving the UI blank in prod.
|
|
# See showcase/scripts/validate-fixture-tool-surface.ts and the
|
|
# postmortem linked from there.
|
|
run: pnpm exec tsx validate-fixture-tool-surface.ts
|
|
|
|
- name: Run validate-pins (ratchet)
|
|
working-directory: showcase/scripts
|
|
# Ratchet gate on pin drift. Baseline (count + SHA-256 hash of sorted
|
|
# unique FAIL lines) lives in `showcase/scripts/fail-baseline.json`;
|
|
# see that file for the full ratchet semantics and adjustment
|
|
# procedure. Weekly backlog visibility is provided by
|
|
# `.github/workflows/showcase_drift-report.yml`. Driving the drift to
|
|
# zero (and flipping this advisory ratchet to fully enforcing) is
|
|
# future work.
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# --- Load + validate baseline -----------------------------------
|
|
# `node -e` prints either a validated value or an error marker
|
|
# we match below. We deliberately do NOT let require() throw
|
|
# out of the subshell; we format a clean CI error instead.
|
|
#
|
|
# We distinguish three failure modes with distinct exit codes so
|
|
# the CI log pinpoints the cause without requiring a re-run:
|
|
# exit 2 => JSON syntax error (require() threw)
|
|
# exit 3 => schema failure (missing/wrong-typed required field)
|
|
# exit 4 => unexpected/unknown top-level field (typo guard)
|
|
#
|
|
# The unexpected-field check rejects silent typos like
|
|
# `validatepinsfailcount` or an accidentally-added `comment`
|
|
# field (distinct from the allowed leading underscore
|
|
# `_comment`) that would otherwise leave required fields
|
|
# undefined and be caught only via the schema branch with a
|
|
# more confusing message.
|
|
set +e
|
|
baseline_json=$(node -e "
|
|
const ALLOWED = ['_comment', 'validatePinsFailCount', 'validatePinsFailHash', 'baselineDemoCount'];
|
|
let v;
|
|
try {
|
|
v = require('./fail-baseline.json');
|
|
} catch (e) {
|
|
console.error('fail-baseline.json: JSON syntax error: ' + e.message);
|
|
process.exit(2);
|
|
}
|
|
const unexpected = Object.keys(v).filter(k => !ALLOWED.includes(k));
|
|
if (unexpected.length > 0) {
|
|
console.error('fail-baseline.json: unexpected field(s): ' + unexpected.join(', ') + '. Allowed fields: ' + ALLOWED.join(', '));
|
|
process.exit(4);
|
|
}
|
|
const c = v.validatePinsFailCount;
|
|
const h = v.validatePinsFailHash;
|
|
if (typeof c !== 'number' || !Number.isInteger(c) || c < 0) {
|
|
console.error('fail-baseline.json: schema failure: validatePinsFailCount must be a non-negative integer');
|
|
process.exit(3);
|
|
}
|
|
if (typeof h !== 'string' || !/^[0-9a-f]{64}$/.test(h)) {
|
|
console.error('fail-baseline.json: schema failure: validatePinsFailHash must be a 64-char lowercase hex SHA-256');
|
|
process.exit(3);
|
|
}
|
|
console.log(JSON.stringify({ count: c, hash: h }));
|
|
")
|
|
rc=$?
|
|
set -e
|
|
if [ "$rc" -ne 0 ]; then
|
|
# Preserve node's distinct rc (2=JSON syntax, 3=schema, 4=unexpected field)
|
|
# in the annotation so the CI log pinpoints the cause.
|
|
echo "::error::fail-baseline.json failed validation (node exit=$rc; 2=JSON syntax, 3=schema, 4=unexpected field)"
|
|
exit "$rc"
|
|
fi
|
|
baseline=$(node -e "console.log(JSON.parse(process.argv[1]).count)" "$baseline_json")
|
|
baseline_hash=$(node -e "console.log(JSON.parse(process.argv[1]).hash)" "$baseline_json")
|
|
|
|
# --- Run validator; separate internal crash from pin-drift exit -
|
|
# validate-pins exits 0 when FAIL=0, 1 when FAIL>0. Anything else
|
|
# (2+, uncaught throw, node crash, SIGSEGV) is an internal failure
|
|
# we must surface distinctly from a legitimate drift report.
|
|
#
|
|
# We deliberately keep stdout and stderr in separate variables.
|
|
# validate-pins.ts emits progress/summary on stdout and `[FAIL]`
|
|
# lines on stderr; mingling them with `2>&1` allowed progress
|
|
# chatter (or future stdout additions) to corrupt the hash input.
|
|
# The hash is computed strictly from stderr.
|
|
set +e
|
|
stderr_file=$(mktemp)
|
|
stdout=$(pnpm exec tsx validate-pins.ts 2>"$stderr_file")
|
|
rc=$?
|
|
stderr=$(cat "$stderr_file")
|
|
rm -f "$stderr_file"
|
|
set -e
|
|
# Replay both streams to the job log so humans can debug.
|
|
printf '%s\n' "$stdout"
|
|
printf '%s\n' "$stderr" >&2
|
|
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
|
|
# Preserve validate-pins.ts's distinct exit code (2=EXIT_INTERNAL,
|
|
# 3=EXIT_UNREADABLE, 4+=future) so downstream consumers can
|
|
# distinguish "validator crashed" from "pin drift found" (which
|
|
# would be rc=1). Collapsing to `exit 1` would make an internal
|
|
# crash indistinguishable from legitimate drift in the PR check
|
|
# signal.
|
|
echo "::error::validate-pins.ts exited with unexpected code $rc (expected 0 or 1). This indicates an internal failure, not pin drift."
|
|
exit "$rc"
|
|
fi
|
|
|
|
# --- Parse Summary line (actual FAIL count) ---------------------
|
|
# Summary line is on stdout. If the validator output format
|
|
# changed (missing Summary, non-numeric FAIL), fail loudly
|
|
# instead of silently treating it as zero.
|
|
#
|
|
# Scope grep's no-match tolerance to grep alone by wrapping just
|
|
# the grep stage in a `{ ... || true; }` group. A trailing
|
|
# `|| true` on the whole pipeline would defeat `pipefail` and
|
|
# swallow producer/head failures too; we only want to tolerate
|
|
# grep finding no match (which `[ -z "$summary_line" ]` below
|
|
# already reports with a precise error).
|
|
summary_line=$(printf '%s\n' "$stdout" | { grep -E '^[[:space:]]*Summary:' || true; } | head -n 1)
|
|
if [ -z "$summary_line" ]; then
|
|
echo "::error::Could not find validate-pins 'Summary:' line in output"
|
|
exit 1
|
|
fi
|
|
# Word-boundary anchored to avoid matching e.g. `NEWFAIL=` or
|
|
# `TOTALFAIL=` if such tokens are ever added to the Summary line.
|
|
actual=$(printf '%s\n' "$summary_line" | grep -oE '\bFAIL=[0-9]+\b' | head -n 1 | cut -d= -f2)
|
|
if [ -z "${actual:-}" ] || ! [[ "$actual" =~ ^[0-9]+$ ]]; then
|
|
echo "::error::Could not parse FAIL=<int> from Summary line: $summary_line"
|
|
exit 1
|
|
fi
|
|
|
|
# --- Compute tuple hash of current FAIL set ---------------------
|
|
# Hash the sorted, deduplicated `[FAIL] ...` lines (stderr only).
|
|
# This catches the "count equal but set drifted" case: one FAIL
|
|
# healed while another regressed.
|
|
#
|
|
# Scope grep's no-match tolerance to grep alone by wrapping just
|
|
# the grep stage in a `{ ... || true; }` group. A trailing
|
|
# `|| true` on the whole pipeline would defeat `pipefail` and
|
|
# swallow sort/shasum/cut failures too; clean runs with zero
|
|
# `[FAIL]` lines must not be an error, so we tolerate grep's
|
|
# no-match here and only here.
|
|
actual_hash=$(printf '%s\n' "$stderr" | { grep -E '^\[FAIL\]' || true; } | LC_ALL=C sort -u | shasum -a 256 | cut -d' ' -f1)
|
|
|
|
echo "validate-pins FAIL: actual=$actual baseline=$baseline"
|
|
echo "validate-pins HASH: actual=$actual_hash baseline=$baseline_hash"
|
|
|
|
if [ "$actual" -gt "$baseline" ]; then
|
|
echo "::error::Pin drift increased: $actual FAIL(s) vs baseline $baseline. Fix the new drift or, with explicit sign-off, update showcase/scripts/fail-baseline.json (bump validatePinsFailCount to $actual and validatePinsFailHash to $actual_hash)."
|
|
exit 1
|
|
fi
|
|
if [ "$actual" -lt "$baseline" ]; then
|
|
echo "::error::Pin drift decreased: $actual FAIL(s) vs baseline $baseline. Ratchet down the baseline in showcase/scripts/fail-baseline.json (set validatePinsFailCount=$actual, validatePinsFailHash=$actual_hash)."
|
|
exit 1
|
|
fi
|
|
if [ "$actual_hash" != "$baseline_hash" ]; then
|
|
echo "::error::Pin drift SET changed (count equal at $actual, hash differs). One FAIL healed while another regressed — net zero on the counter but the failing tuples are not the same set. Update showcase/scripts/fail-baseline.json (validatePinsFailHash=$actual_hash) if this is intentional, or fix the new drift."
|
|
echo "--- FAIL lines (current) ---"
|
|
printf '%s\n' "$stderr" | grep -E '^\[FAIL\]' | LC_ALL=C sort -u
|
|
exit 1
|
|
fi
|
|
echo "Pin drift unchanged at baseline ($baseline, hash $baseline_hash)."
|
|
|
|
- name: Run build pipeline tests
|
|
working-directory: showcase/scripts
|
|
# Use pnpm to resolve the workspace-installed vitest (pinned via
|
|
# pnpm-lock.yaml) rather than `npx`, which could fetch a different
|
|
# version on a registry cache miss.
|
|
run: pnpm exec vitest run
|
|
|
|
- name: CVDIAG emit perf-regression gate
|
|
working-directory: showcase/harness
|
|
# Perf gate for the CVDIAG `CvdiagEmitter` hot path (plan unit L2-D).
|
|
# Pure instrumentation must stay cheap on the boundary it observes:
|
|
# spec §7 sets a 500µs/event prod budget; this gate holds emit at 50%
|
|
# of that for headroom — per-event median ≤100µs, p99 ≤250µs — and the
|
|
# bench's teardown throws (failing this step, and the job) on a >20%
|
|
# regression past either threshold. vitest `bench` is experimental but
|
|
# stable for this single-task run; the throw-on-breach is what gates,
|
|
# not the (advisory) hz/p99 table. Resolve vitest via pnpm so the
|
|
# pnpm-lock.yaml-pinned version is used (no registry-cache-miss drift).
|
|
run: pnpm exec vitest bench src/cvdiag/emit-perf.bench.ts --run
|
|
|
|
- name: Validate manifests & generate registry
|
|
working-directory: showcase/scripts
|
|
run: pnpm exec tsx generate-registry.ts
|
|
|
|
# ADVISORY ONLY — never fail the build. The promote dropdown is
|
|
# self-healed by the lefthook pre-commit hook; this step only warns if a
|
|
# commit somehow lands with a drifted showcase_promote.yml `service`
|
|
# dropdown (e.g. hook skipped). The trailing `|| true` keeps a non-zero
|
|
# `--check` exit from reddening validate.
|
|
- name: Advisory — promote dropdown drift check
|
|
working-directory: showcase/scripts
|
|
run: |
|
|
# ADVISORY ONLY: capture the exit code without letting a non-zero
|
|
# `--check` redden the build. `|| true` alone would discard rc and
|
|
# collapse every failure mode into the misleading "stale, re-run"
|
|
# warning. sync-promote-service-options.ts exits:
|
|
# 1 => drift (dropdown out of date; re-running the generator fixes it)
|
|
# 2 => read error
|
|
# 3 => missing/duplicate/malformed marker block (corruption)
|
|
# Only rc=1 is actually self-heals-by-rerun; rc>=2 needs a human, and
|
|
# the suggested re-run would itself fail — so report it distinctly.
|
|
set +e
|
|
pnpm exec tsx sync-promote-service-options.ts --check
|
|
rc=$?
|
|
set -e
|
|
if [ "$rc" -eq 1 ]; then
|
|
echo "::warning::showcase_promote.yml service dropdown is stale. Run: npx tsx showcase/scripts/sync-promote-service-options.ts and commit the result."
|
|
elif [ "$rc" -ge 2 ]; then
|
|
echo "::warning::sync-promote-service-options.ts failed (exit $rc) — marker block missing/duplicated or read error; investigate before trusting the dropdown."
|
|
fi
|
|
# Always succeed: this step must never fail the build (the lefthook
|
|
# pre-commit hook self-heals; CI only warns).
|
|
exit 0
|
|
|
|
- name: Bundle demo content
|
|
working-directory: showcase/scripts
|
|
run: pnpm exec tsx bundle-demo-content.ts
|
|
|
|
- name: Install showcase shell dependencies
|
|
working-directory: showcase/shell
|
|
# `showcase/shell` is NOT a pnpm workspace member (see pnpm-workspace.yaml)
|
|
# and ships its own `package-lock.json`. Use `npm ci` to get a
|
|
# reproducible install; `npm install` would re-resolve ranges.
|
|
# npm cache is configured at the setup-node step above via
|
|
# `cache-dependency-path: showcase/shell/package-lock.json`.
|
|
run: npm ci --ignore-scripts
|
|
|
|
- name: Build showcase shell
|
|
working-directory: showcase/shell
|
|
run: npm run build
|
|
|
|
# NOTE: Slack failure alert only fires on `push` (i.e. main-branch
|
|
# merges) by design. PR failures already surface in the PR checks UI
|
|
# and the PR author's inbox, and we don't want PR-author noise
|
|
# pinging the OSS alerts channel. Tradeoff: a broken PR that sneaks
|
|
# past review won't alert Slack until after merge.
|
|
#
|
|
# Extract the failed step name and first meaningful error line so the
|
|
# Slack payload is actionable at a glance rather than forcing a
|
|
# click-through to the workflow run. Bare "X failed" alerts bury the
|
|
# signal; red alerts must carry triage-ready detail per the oss-alerts
|
|
# policy. Writes `failed_step` and `error_excerpt` to $GITHUB_ENV for
|
|
# consumption by the notify step below.
|
|
#
|
|
# This step must NEVER fail the job (it runs on failure() already; a
|
|
# crash here would compound the original failure with extraction
|
|
# noise and could block the notify step). All extraction uses `|| true`
|
|
# fallbacks so a malformed jobs response or truncated log still yields
|
|
# sane defaults ("unknown" / "see workflow run for details").
|
|
- name: Extract failure details for Slack
|
|
id: extract
|
|
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
GH_REPO: ${{ github.repository }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
run: |
|
|
set +e # best-effort: never block the notify step below
|
|
|
|
# --- Find the currently-running job and its first failed step ---
|
|
# The jobs API returns every job in the run. We identify *this*
|
|
# job by name (matches `jobs.validate.name`) rather than
|
|
# job.status=='in_progress', because at this point the step we're
|
|
# running hasn't flipped the job state yet in the API. Fall back
|
|
# to the first job with a failed step if the name match misses
|
|
# (e.g. future rename drift).
|
|
jobs_json=$(gh api "/repos/${GH_REPO}/actions/runs/${RUN_ID}/jobs" --paginate 2>/dev/null)
|
|
job_id=$(printf '%s' "$jobs_json" | jq -r '
|
|
.jobs // []
|
|
| map(select(.name == "Validate Showcase"))
|
|
| (.[0].id // empty)
|
|
' 2>/dev/null)
|
|
if [ -z "$job_id" ]; then
|
|
job_id=$(printf '%s' "$jobs_json" | jq -r '
|
|
.jobs // []
|
|
| map(select(.steps // [] | map(.conclusion) | index("failure")))
|
|
| (.[0].id // empty)
|
|
' 2>/dev/null)
|
|
fi
|
|
failed_step=$(printf '%s' "$jobs_json" | jq -r --arg id "$job_id" '
|
|
.jobs // []
|
|
| map(select((.id|tostring) == $id))
|
|
| (.[0].steps // [])
|
|
| map(select(.conclusion == "failure"))
|
|
| (.[0].name // "unknown step")
|
|
' 2>/dev/null)
|
|
[ -z "$failed_step" ] && failed_step="unknown step"
|
|
|
|
# --- Pull log and extract first meaningful error line ------------
|
|
# `gh run view --log-failed` output is TSV: job\tstep\ttimestamp + content.
|
|
# Strip the three leading columns to get the raw step output, strip
|
|
# ANSI escape codes, strip any stray BOM, skip runner/group/env
|
|
# header noise, then grab the first line matching a recognised
|
|
# error marker. Truncate to ~300 chars so the Slack payload stays
|
|
# well under the 800-char budget even with escaping overhead.
|
|
error_excerpt="see workflow run for details"
|
|
if [ -n "$job_id" ]; then
|
|
log_excerpt=$(gh run view "$RUN_ID" --repo "$GH_REPO" --log-failed --job="$job_id" 2>/dev/null \
|
|
| awk -F'\t' 'NF>=3 { sub(/^[\xEF\xBB\xBF]?[0-9T:.\-Z ]+/, "", $3); print $3 }' \
|
|
| sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
|
|
| grep -vE '^(##\[|shell: |env: |Run |[[:space:]]*$)' \
|
|
| grep -m1 -E '^\[(FAIL|ERROR)\]|^Error:|^error:|^::error' \
|
|
| head -c 300)
|
|
if [ -n "$log_excerpt" ]; then
|
|
error_excerpt="$log_excerpt"
|
|
fi
|
|
fi
|
|
|
|
# --- Emit to $GITHUB_ENV using heredoc delimiter -----------------
|
|
# Heredoc delimiter protects against values that contain `=` or
|
|
# newlines breaking the KEY=VALUE format. The delimiter is a
|
|
# long random-ish string unlikely to appear in any log line.
|
|
{
|
|
echo "failed_step<<EOF_FAILED_STEP_b3f2"
|
|
printf '%s\n' "$failed_step"
|
|
echo "EOF_FAILED_STEP_b3f2"
|
|
echo "error_excerpt<<EOF_ERROR_EXCERPT_b3f2"
|
|
printf '%s\n' "$error_excerpt"
|
|
echo "EOF_ERROR_EXCERPT_b3f2"
|
|
} >> "$GITHUB_ENV"
|
|
|
|
exit 0 # belt-and-suspenders: never propagate a failure
|
|
|
|
- name: Mark inline Slack notifier reached
|
|
id: inline_slack_marker
|
|
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
|
|
run: echo "reached=true" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Notify Slack (failure)
|
|
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
|
|
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
|
with:
|
|
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
|
webhook-type: incoming-webhook
|
|
# Defensive: wrap dynamic values via toJSON(format(...)) so that
|
|
# if github.repository or the extracted failed_step / error_excerpt
|
|
# contain characters that would break the JSON payload (quotes,
|
|
# backslashes, newlines), the value is safely JSON-encoded instead
|
|
# of injected as raw text. Matches the pattern used in
|
|
# showcase_drift-report.yml. github.run_id is numeric so safe on
|
|
# its own, but we wrap it for consistency and defense-in-depth.
|
|
# env.failed_step and env.error_excerpt are populated by the
|
|
# preceding "Extract failure details" step (with safe fallbacks if
|
|
# extraction fails).
|
|
payload: |
|
|
{ "text": ${{ toJSON(format(':x: *Showcase validate*: failed — {0}: {1} | <https://github.com/{2}/actions/runs/{3}|View run>', env.failed_step, env.error_excerpt, github.repository, github.run_id)) }} }
|
|
|
|
- name: Log (no Slack — webhook unset)
|
|
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK == ''
|
|
run: |
|
|
echo "::warning::showcase_validate failed on push but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
|
|
|
|
shell-script-tests:
|
|
name: Shell script tests (bats + shellcheck)
|
|
# Separate job (mirrors python-unit-tests) so the showcase shell-script
|
|
# regression suite runs independently of the JS/TS validate job. Runs on
|
|
# ubuntu-latest where shellcheck is preinstalled; bats is apt-installed.
|
|
# These tests gate the promote-fleet.sh best-effort loop + succeeded_csv
|
|
# export that the promote → verify-prod handoff depends on.
|
|
runs-on: ubuntu-latest
|
|
# The bats suite runs ~4.5min and keeps growing; at the old 5min job cap it
|
|
# raced the deadline and intermittently got cancelled mid-suite (all steps
|
|
# passing) rather than reported. Give headroom so a green suite reports green.
|
|
timeout-minutes: 10
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Install bats
|
|
run: |
|
|
# GitHub's ubuntu-latest runner image preconfigures third-party apt
|
|
# repos (Microsoft / azure-cli) for preinstalled tooling this job does
|
|
# not use. When one of those repos serves invalid release metadata,
|
|
# `apt-get update` exits non-zero and `bash -e` aborts the step —
|
|
# even though bats comes from Ubuntu's own `universe` repo, which is
|
|
# unaffected. This job only needs Ubuntu packages, so drop those unused
|
|
# third-party repos before updating.
|
|
sudo rm -f /etc/apt/sources.list.d/*microsoft* /etc/apt/sources.list.d/*azure-cli*
|
|
sudo apt-get update
|
|
sudo apt-get install -y bats
|
|
|
|
- name: Shellcheck promote workflow scripts
|
|
# shellcheck is preinstalled on ubuntu-latest.
|
|
run: shellcheck showcase/scripts/promote-fleet.sh showcase/scripts/verify-prod-display.sh showcase/scripts/reconcile-prod-gate.sh
|
|
|
|
- name: Run bats suite
|
|
run: bats showcase/scripts/__tests__/
|
|
|
|
python-unit-tests:
|
|
name: Python unit tests (${{ matrix.python-version }})
|
|
# Separate job so pre-existing `validate-parity` failures don't mask new
|
|
# Python unit-test regressions. pytest runs independently of JS/TS checks.
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
permissions:
|
|
contents: read
|
|
strategy:
|
|
# Fail-fast disabled so a 3.10-only regression (e.g. typing_extensions
|
|
# fallback path breaking) doesn't cancel the 3.12 run and leave us
|
|
# guessing which version is the actual problem.
|
|
fail-fast: false
|
|
matrix:
|
|
# 3.10 covers the typing_extensions `NotRequired` fallback path used
|
|
# by aimock_toggle.py (stdlib `NotRequired` only landed in 3.11).
|
|
# 3.12 is the production/runner default. Pinning both guarantees we
|
|
# catch a regression in either branch the first time it lands.
|
|
python-version: ["3.10", "3.12"]
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Setup Python
|
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
with:
|
|
python-version: ${{ matrix.python-version }}
|
|
cache: "pip"
|
|
cache-dependency-path: |
|
|
showcase/integrations/*/requirements.txt
|
|
|
|
- name: Install minimal test deps
|
|
# Always need pytest + typing_extensions. pytest-asyncio is required
|
|
# by langroid's test_agui_adapter.py (16 tests use
|
|
# `@pytest.mark.asyncio`); without it, pytest reports
|
|
# "async def functions are not natively supported" and skips them.
|
|
# pytest-mock is installed pre-emptively as it's commonly used by
|
|
# showcase package tests and is cheap to install.
|
|
# Per-package `requirements.txt` is installed inside the run loop
|
|
# below so tests that import runtime deps (openai, google.genai,
|
|
# httpx, opentelemetry, etc.) don't fail at collection time with
|
|
# ModuleNotFoundError. Conftest-based stub finders can't help
|
|
# because test_*.py imports the target deps BEFORE conftest runs.
|
|
run: python -m pip install --quiet pytest pytest-asyncio pytest-mock typing_extensions
|
|
|
|
- name: Run showcase package Python unit tests
|
|
# Keep scope narrow: only showcase/integrations/*/tests/python/ directories
|
|
# (not e2e, not langgraph which has its own runtime). Each package has
|
|
# its own conftest.py that wires up import paths; we cd into the pkg
|
|
# dir so those apply.
|
|
#
|
|
# Before running pytest in a package we install that package's own
|
|
# `requirements.txt` (if present) so runtime-dep imports in test modules
|
|
# resolve. Keeps CI parity with real runtime and avoids the fragile
|
|
# stub-finder dance conftest.py would need to do otherwise.
|
|
run: |
|
|
set -euo pipefail
|
|
failed=0
|
|
found=0
|
|
# Current interpreter major.minor (e.g. "3.10", "3.12"). Used
|
|
# below to skip packages whose runtime deps are incompatible
|
|
# with the matrix Python on this job.
|
|
py_mm=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
|
for pkg_dir in showcase/integrations/*/; do
|
|
tests_dir="${pkg_dir}tests/python"
|
|
[ -d "$tests_dir" ] || continue
|
|
found=$((found + 1))
|
|
pkg=$(basename "$pkg_dir")
|
|
# --- Per-package Python-version gates -------------------------
|
|
# Skip packages whose `requirements.txt` pins a dep whose
|
|
# `requires-python` excludes this interpreter. Surgical skip
|
|
# (not matrix exclusion) so the rest of the packages continue
|
|
# to exercise the 3.10 typing_extensions fallback path.
|
|
#
|
|
# claude-sdk-python: ag-ui-claude-sdk declares `requires-python >=3.11`;
|
|
# the package Dockerfile and production runner use Python 3.12.
|
|
# strands: ag_ui_strands==0.1.0 declares `requires-python >=3.12,<3.14`,
|
|
# so `pip install` fails on 3.10 before pytest even runs.
|
|
# langroid: tests import `typing.Self` (3.11+); on 3.10 the import fails
|
|
# at collection time. typing_extensions.Self would fix it but the tests
|
|
# are tightly coupled to the modern typing module.
|
|
# Revisit when ag_ui_strands relaxes its floor or when 3.10 is dropped.
|
|
if [ "$py_mm" = "3.10" ] && { [ "$pkg" = "claude-sdk-python" ] || [ "$pkg" = "strands" ] || [ "$pkg" = "langroid" ]; }; then
|
|
echo "--- pytest: $pkg --- SKIPPED on Python $py_mm (requires >=3.11/3.12)"
|
|
continue
|
|
fi
|
|
echo "--- pytest: $pkg ---"
|
|
if [ -f "${pkg_dir}requirements.txt" ]; then
|
|
echo "Installing ${pkg_dir}requirements.txt"
|
|
python -m pip install --quiet -r "${pkg_dir}requirements.txt" || {
|
|
echo "::error::pip install failed for $pkg"
|
|
failed=1
|
|
continue
|
|
}
|
|
fi
|
|
# Export PYTHONPATH so `from tools import ...` in agent modules
|
|
# resolves via the `tools` symlink at the integration root.
|
|
# Also include src/ so `from agents.X import ...` works even if
|
|
# a conftest.py omits the sys.path setup. Mirrors the local dev
|
|
# convention (`PYTHONPATH=. python ...` in package.json scripts).
|
|
(cd "$pkg_dir" && PYTHONPATH=".:src:${PYTHONPATH:-}" python -m pytest tests/python/ -v) || failed=1
|
|
done
|
|
if [ "$found" -eq 0 ]; then
|
|
echo "::warning::No showcase/integrations/*/tests/python/ directories found"
|
|
fi
|
|
exit "$failed"
|
|
|
|
notify:
|
|
# Slack #oss-alerts on any red. Never #engr (engr is sacred — release alerts only).
|
|
# Mirrors the workflow-level notify pattern in showcase_promote.yml so
|
|
# red runs surface uniformly across the showcase pipeline. The validate
|
|
# job has its own inline (and richer) push-only notifier that extracts
|
|
# the failing step + error excerpt; this job is the workflow-level
|
|
# safety net that also covers the python-unit-tests matrix job and the
|
|
# shell-script-tests job — which otherwise had no Slack signal at all.
|
|
# Webhook empty-guard mirrors promote.yml so an unset
|
|
# SLACK_WEBHOOK_OSS_ALERTS secret does not break the shell or red the
|
|
# workflow on this step.
|
|
needs: [validate, python-unit-tests, shell-script-tests]
|
|
if: always() && github.event_name == 'push' && !(needs.validate.result == 'failure' && needs.validate.outputs.inline_slack_notifier_reached == 'true' && needs.python-unit-tests.result == 'success' && needs.shell-script-tests.result == 'success')
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 3
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
env:
|
|
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
|
steps:
|
|
- name: Compute state
|
|
id: state
|
|
env:
|
|
VALIDATE: ${{ needs.validate.result }}
|
|
PYTEST: ${{ needs.python-unit-tests.result }}
|
|
SHELL: ${{ needs.shell-script-tests.result }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ "$VALIDATE" = "success" ] && [ "$PYTEST" = "success" ] && [ "$SHELL" = "success" ]; then
|
|
STATE="success"; ICON=":white_check_mark:"
|
|
else
|
|
STATE="failure"; ICON=":x:"
|
|
fi
|
|
{
|
|
echo "state=$STATE"
|
|
echo "icon=$ICON"
|
|
} >> "$GITHUB_OUTPUT"
|
|
- name: Post to #oss-alerts
|
|
if: steps.state.outputs.state == 'failure' && env.SLACK_WEBHOOK != ''
|
|
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
|
with:
|
|
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
|
webhook-type: incoming-webhook
|
|
# Newlines are injected via fromJSON('"\n"') (a real LF char) as {8},
|
|
# NOT a literal '\n' in the template: GitHub Actions expression string
|
|
# literals do not interpret backslash escapes, so a literal '\n' would
|
|
# survive toJSON as the two chars \\n and Slack would render it
|
|
# verbatim as "\n" instead of a line break.
|
|
payload: |
|
|
{
|
|
"text": ${{ toJSON(format(
|
|
'{0} *showcase_validate failed on {1}*{8}validate={2} python-unit-tests={3} shell-script-tests={4}{8}<{5}/{6}/actions/runs/{7}|View run>',
|
|
steps.state.outputs.icon,
|
|
github.ref,
|
|
needs.validate.result,
|
|
needs.python-unit-tests.result,
|
|
needs.shell-script-tests.result,
|
|
github.server_url,
|
|
github.repository,
|
|
github.run_id,
|
|
fromJSON('"\n"')
|
|
)) }}
|
|
}
|
|
- name: Log (no Slack — webhook unset)
|
|
if: steps.state.outputs.state == 'failure' && env.SLACK_WEBHOOK == ''
|
|
env:
|
|
REF: ${{ github.ref }}
|
|
run: |
|
|
echo "::warning::showcase_validate failed on $REF but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
|