Skip to content

ci: make path-filtered required gates always report (unblock stuck PR… #285

ci: make path-filtered required gates always report (unblock stuck PR…

ci: make path-filtered required gates always report (unblock stuck PR… #285

Workflow file for this run

# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# Full test suite for BoJ Server: E2E, aspect tests, and benchmarks.
# Covers all merge requirement categories: P2P (zig-test.yml), E2E (here),
# aspect, execution, lifecycle, and benchmarks.
name: E2E + Aspect + Bench
# Was workflow-level path-filtered: required checks that never ran on PRs
# touching none of its paths, leaving them "Expected" and the PR blocked.
# Now always runs; the `changes` job gates the heavy jobs, which when skipped
# report SUCCESS to required checks.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master]
workflow_dispatch:
permissions: read-all
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
name: Detect relevant changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
run: ${{ steps.detect.outputs.run }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- id: detect
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.base_ref }}
BEFORE: ${{ github.event.before }}
run: |
set -uo pipefail
run=true
if [ "$EVENT" = pull_request ]; then
git fetch --no-tags --depth=200 origin "$BASE" 2>/dev/null \
&& changed=$(git diff --name-only "origin/${BASE}...HEAD" 2>/dev/null) \
&& { printf '%s\n' "$changed" | grep -qE '^adapter/|^cartridges/|^ffi/|^mcp-bridge/|^tests/|^src/' && run=true || run=false; }
elif [ "$EVENT" = push ] && [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then
changed=$(git diff --name-only "${BEFORE}...${GITHUB_SHA}" 2>/dev/null) \
&& { printf '%s\n' "$changed" | grep -qE '^adapter/|^cartridges/|^ffi/|^mcp-bridge/|^tests/|^src/' && run=true || run=false; }
fi
printf 'run=%s\n' "$run" >> "$GITHUB_OUTPUT"
echo "relevant=$run; changed files:"; printf '%s\n' "${changed:-<none computed>}"
# ─── End-to-End: Full REST + MCP Bridge ────────────────────────────
e2e-full:
name: E2E — Full REST + MCP Bridge
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Zig
uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2
with:
version: 0.15.1
- name: Install Deno
uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
with:
deno-version: v2.x
- name: Install Elixir + OTP
# tests/e2e_full.sh requires `mix` on PATH to start the Elixir
# backend (elixir/ — `mix run --no-halt`). Pinned to match the
# estate convention (see hypatia-scan.yml across the org).
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2
with:
elixir-version: '1.18'
otp-version: '27'
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y curl jq
- name: Fetch Elixir deps
working-directory: elixir
run: mix deps.get
- name: Compile Elixir deps
# Pre-compile so the in-test `mix run --no-halt` doesn't have
# to do it on the critical path of the 60s server-start window.
working-directory: elixir
run: mix compile
- name: Build FFI libraries
run: |
# Subshell keeps the cd contained so the cartridge loop below runs
# from the repo root (otherwise its glob never matches). `zig build
# invoke` builds the boj-invoke CLI the Elixir Invoker shells out to
# for tool dispatch — without it every FFI invoke returns {} (cli_missing).
(cd ffi/zig && zig build && zig build invoke)
for cart in cartridges/*/ffi; do
if [ -f "$cart/build.zig" ]; then (cd "$cart" && zig build) || true; fi
done
- name: Run E2E full test suite
run: bash tests/e2e_full.sh
- name: Upload test logs
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-full-logs
path: /tmp/boj-e2e-test.*
retention-days: 7
# ─── End-to-End: Order Ticket (FFI layer) ──────────────────────────
e2e-order-ticket:
name: E2E — Order Ticket (FFI layer)
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Zig
uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2
with:
version: 0.15.1
- name: Build FFI libraries
run: cd ffi/zig && zig build
- name: Run order ticket E2E
run: bash tests/order_ticket_e2e.sh
# ─── Aspect Tests: Cross-Cutting Concerns ──────────────────────────
aspect-tests:
name: Aspect — Thread Safety + ABI Contract + SPDX
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run aspect tests
run: bash tests/aspect_tests.sh
# ─── Benchmarks: Performance Regression Detection ──────────────────
benchmarks:
name: Bench — FFI Catalogue + Mount/Unmount + Hash
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Zig
uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2
with:
version: 0.15.1
- name: Build FFI libraries
run: cd ffi/zig && zig build
- name: Run benchmarks
run: cd ffi/zig && zig build bench
- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: benchmark-results
path: ffi/zig/zig-out/bench*
retention-days: 30
# ─── Bridge Tests: unit suite + boot smoke × {Node, Deno, Bun} ─────
# The README blesses three runtimes for the zero-dependency bridge:
# Deno (documented runtime), Node (the npx install path every client
# quickstart uses), and Bun (zero-install). Only Deno is exercised by
# development, so a runtime leak in the other two ships silently — a
# bare `Deno.env` reference once broke the entire npx path at import
# time (fixed in PR #211). This job runs the full unit suite AND an
# MCP initialize → tools/list boot smoke of main.js under each
# runtime. The smoke matters separately: the unit suite imports lib/
# but never boots main.js, whose stdio/exit paths are runtime-gated.
bridge-tests:
name: Bridge — ${{ matrix.runtime }} (unit + boot smoke)
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- runtime: node
unit: node --test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
boot: node mcp-bridge/main.js
- runtime: deno
unit: deno test --allow-read --allow-env --allow-run --allow-net mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
# Scoped perms matching main.js's shebang, NOT -A: the boot smoke
# must exercise the exact permission set a real install uses, or it
# would mask a missing-grant bug that scoped users would hit.
boot: deno run --allow-net --allow-env --allow-read mcp-bridge/main.js
- runtime: bun
unit: bun test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
boot: bun mcp-bridge/main.js
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
# Node is needed on every leg: it is the subject on the node
# leg and the boot-smoke orchestrator on all three.
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'
- name: Install Deno
if: matrix.runtime == 'deno'
uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
with:
deno-version: v2.x
- name: Install Bun
if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: '1.x'
- name: Unit suite (${{ matrix.runtime }})
run: ${{ matrix.unit }}
- name: Boot smoke (${{ matrix.runtime }})
run: node mcp-bridge/tests/boot_smoke.js ${{ matrix.boot }}
# ─── Bench Bridge: mcp-bridge JS perf (path-claims) ────────────────
# Separate job from `benchmarks` above because the bridge has no Zig
# toolchain dependency — keeps logs untangled and lets the JS bench
# run in parallel on its own runner. Posts a sticky PR comment so
# deltas across pushes are visible inline.
bench-bridge:
name: Bench — mcp-bridge (path-claims)
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
# Per-job override: only this step needs to write PR comments.
# Workflow-level permissions stay read-all.
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'
- name: Run bridge bench
run: node mcp-bridge/tests/path_claims_bench.js | tee bench-bridge.txt
- name: Upload bridge bench artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: bench-bridge-results
path: bench-bridge.txt
retention-days: 30
- name: Sticky PR comment with bench numbers
if: github.event_name == 'pull_request'
# Advisory — a comment failure must never gate the bench job.
# Same reasoning as the hypatia-scan PR-comment step.
continue-on-error: true
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
with:
script: |
const fs = require('fs');
const MARKER = '<!-- bench-bridge:path-claims -->';
const output = fs.readFileSync('bench-bridge.txt', 'utf8');
const body = `${MARKER}\n## 🏁 path-claims bench\n\nCommit \`${context.sha.slice(0, 7)}\`\n\n<details open><summary>Numbers</summary>\n\n\`\`\`\n${output}\n\`\`\`\n</details>\n\n*Host-dependent — compare deltas across commits, not absolute values.*`;
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const existing = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 },
);
const prior = existing.find((c) => c.body && c.body.includes(MARKER));
if (prior) {
await github.rest.issues.updateComment({
owner, repo, comment_id: prior.id, body,
});
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body,
});
}