Skip to content

Commit 8e5da35

Browse files
committed
[ADD] CI jobs for demo runtime proof and runtime integration with branch protection notes
1 parent ef306c9 commit 8e5da35

8 files changed

Lines changed: 144 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ on:
77
pull_request:
88
workflow_dispatch:
99
inputs:
10+
run_demo_runtime:
11+
description: 'Run demo runtime proof (make demo-all + artifacts)'
12+
required: false
13+
default: false
14+
type: boolean
1015
run_runtime_integration:
1116
description: 'Run runtime integration checks against deployed environment'
1217
required: false
@@ -37,7 +42,11 @@ jobs:
3742

3843
runtime-integration:
3944
needs: checks
40-
if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_runtime_integration == true }}
45+
if: >-
46+
${{
47+
(github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/'))
48+
|| (github.event_name == 'workflow_dispatch' && inputs.run_runtime_integration == true)
49+
}}
4150
runs-on: ubuntu-latest
4251
env:
4352
EMCP_INTEGRATION_ENABLED: '1'
@@ -61,5 +70,74 @@ jobs:
6170
- name: Install dependencies
6271
run: composer install --no-interaction --prefer-dist --no-progress
6372

73+
- name: Validate live runtime secrets
74+
run: |
75+
set -eu
76+
missing=""
77+
for var in EMCP_BASE_URL EMCP_SERVER_HANDLE EMCP_API_PATH EMCP_API_TOKEN; do
78+
eval "value=\${$var:-}"
79+
if [ -z "$value" ]; then
80+
missing="$missing $var"
81+
fi
82+
done
83+
if [ -n "$missing" ]; then
84+
echo "Missing required secrets/env for runtime integration:$missing" >&2
85+
exit 1
86+
fi
87+
6488
- name: Runtime integration checks
65-
run: composer run test:integration:runtime
89+
run: |
90+
set -eu
91+
export EMCP_DISPATCH_CHECK="${EMCP_DISPATCH_CHECK:-1}"
92+
composer run test:integration:runtime | tee runtime-live.log
93+
94+
- name: Upload runtime integration artifact
95+
if: always()
96+
uses: actions/upload-artifact@v4
97+
with:
98+
name: runtime-live-log
99+
path: runtime-live.log
100+
if-no-files-found: warn
101+
102+
demo-runtime-proof:
103+
needs: checks
104+
if: >-
105+
${{
106+
(github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/'))
107+
|| (github.event_name == 'workflow_dispatch' && inputs.run_demo_runtime == true)
108+
}}
109+
runs-on: ubuntu-latest
110+
env:
111+
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
112+
DEMO_JWT_SECRET: emcp-demo-secret-0123456789abcdef0123456789abcdef
113+
steps:
114+
- name: Checkout
115+
uses: actions/checkout@v4
116+
117+
- name: Setup PHP
118+
uses: shivammathur/setup-php@v2
119+
with:
120+
php-version: '8.4'
121+
coverage: none
122+
123+
- name: Install dependencies
124+
run: composer install --no-interaction --prefer-dist --no-progress
125+
126+
- name: Install Evolution installer
127+
run: |
128+
set -eu
129+
composer global require evolution-cms/installer --no-interaction --no-progress
130+
echo "$(composer global config bin-dir --absolute)" >> "$GITHUB_PATH"
131+
132+
- name: Run demo runtime proof
133+
run: make demo-all
134+
135+
- name: Upload demo runtime artifacts
136+
if: always()
137+
uses: actions/upload-artifact@v4
138+
with:
139+
name: demo-runtime-artifacts
140+
path: |
141+
demo/logs.md
142+
/tmp/emcp-demo-php-server.log
143+
if-no-files-found: warn

PRD.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
- Автоматично генерується `demo/logs.md` з деталями токена, MCP запитів/відповідей, manual-check командами і негативними probe-кейсами (`401/403/413/415/409/429`, `evo.model.get(User)` sanity).
2121

2222
Залишок до RC-1 (core platform hardening):
23-
- live runtime integration job у GitHub Actions зі staging env/secrets;
23+
- branch protection required-check enforcement для CI runtime jobs (`demo-runtime-proof`, `runtime-integration`) на `release/*`;
2424
- live async checks для `sTask` (progress/result/retry/failover);
2525
- live stream/rate-limit операційні перевірки;
2626
- зафіксовані RC evidence артефакти (security sanity + performance baseline).

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ EMCP_DISPATCH_CHECK=1 \
280280
composer run test:integration:runtime
281281
```
282282

283+
CI release note:
284+
- `.github/workflows/ci.yml` runs `demo-runtime-proof` and `runtime-integration` on `release/*` pushes.
285+
- Configure branch protection to make these jobs required for RC/release merges.
286+
283287
## Async (sTask-first)
284288
If `queue.driver=stask` and `sTask` is installed, eMCP can run long MCP calls via worker `emcp_dispatch`.
285289
If sTask is missing, fallback behavior follows `queue.failover` (`sync` or `fail`).

README.uk.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@ EMCP_DISPATCH_CHECK=1 \
279279
composer run test:integration:runtime
280280
```
281281

282+
CI release примітка:
283+
- `.github/workflows/ci.yml` запускає `demo-runtime-proof` і `runtime-integration` на push у `release/*`.
284+
- Для RC/release merge треба увімкнути branch protection і позначити ці jobs як required checks.
285+
282286
## Async через sTask
283287
Якщо `queue.driver=stask` і `sTask` встановлений, довгі MCP виклики виконуються через воркер `emcp_dispatch`.
284288
Якщо `sTask` відсутній — fallback визначається `queue.failover` (`sync` або `fail`).

SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Current validation snapshot (2026-03-03):
2020
- One-click verification writes `demo/logs.md` with request/response evidence, negative transport/security probes (`401/403/413/415/409/429`) and model-safety sanity (`evo.model.get(User)` without sensitive fields).
2121

2222
Open RC-1 validation scope:
23-
- live CI runtime integration (external env/secrets) is not yet mandatory-on-push;
23+
- live CI runtime integration jobs are wired for `release/*` pushes (`demo-runtime-proof`, `runtime-integration`), but branch-protection required-check enforcement must be configured in repository settings;
2424
- live async `sTask` e2e checks (queue lifecycle/progress/failover) are not yet enforced in CI;
2525
- live stream/rate-limit infra checks remain pending as RC evidence.
2626

TASKS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ DoD:
233233
- [x] Baseline unit test for model allowlist leakage (sensitive fields never exposed).
234234
- [ ] Integration tests for manager/API MCP endpoints.
235235
- [x] Add runtime integration harness script for manager/API/dispatch verification against deployed environment.
236+
- [x] Add release-branch CI runtime jobs (`demo-runtime-proof`, `runtime-integration`) with artifacts (`demo/logs.md`, `runtime-live.log`).
237+
- [ ] Configure repository branch protection to require `demo-runtime-proof` and `runtime-integration` on `release/*`.
236238
- [ ] Streaming tests under typical PHP-FPM constraints.
237239
- [ ] Async tests for `sTask` path and failover.
238240
- [x] Baseline feature-behavior test for dispatch idempotency semantics (`reuse` and `409 conflict`) with policy deny path.

scripts/demo_verify.sh

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ model_get_user_payload='{"jsonrpc":"2.0","id":"model-doc","method":"tools/call",
384384
dispatch_payload_a='{"jsonrpc":"2.0","id":"d1-doc","method":"tools/call","params":{"name":"evo.content.search","arguments":{"limit":1,"offset":0}}}'
385385
dispatch_payload_b='{"jsonrpc":"2.0","id":"d2-doc","method":"tools/call","params":{"name":"evo.content.search","arguments":{"limit":2,"offset":0}}}'
386386
dispatch_url="${mcp_url}/dispatch"
387+
rate_limit_identity_type='api:jwt (sapi.jwt.user_id/sapi.jwt.sub; fallback ip)'
387388

388389
unauth_headers_file="${TMP_DIR}/unauth.headers"
389390
unauth_raw=$(curl -sS -D "${unauth_headers_file}" \
@@ -455,10 +456,15 @@ dispatch_a_raw=$(mcp_post_with_token_optional_session "${probe_token}" "${dispat
455456
dispatch_a_http_code=$(printf '%s' "${dispatch_a_raw}" | sed -n 's/^__HTTP_CODE__://p' | tail -n 1)
456457
dispatch_a_response=$(printf '%s' "${dispatch_a_raw}" | sed '/^__HTTP_CODE__:/d')
457458

458-
dispatch_b_headers="${TMP_DIR}/dispatch-b.headers"
459-
dispatch_b_raw=$(mcp_post_with_token_optional_session "${probe_token}" "${dispatch_payload_b}" "${dispatch_b_headers}" "Idempotency-Key: demo-verify-k1" "${dispatch_url}")
460-
dispatch_b_http_code=$(printf '%s' "${dispatch_b_raw}" | sed -n 's/^__HTTP_CODE__://p' | tail -n 1)
461-
dispatch_b_response=$(printf '%s' "${dispatch_b_raw}" | sed '/^__HTTP_CODE__:/d')
459+
dispatch_reuse_headers="${TMP_DIR}/dispatch-reuse.headers"
460+
dispatch_reuse_raw=$(mcp_post_with_token_optional_session "${probe_token}" "${dispatch_payload_a}" "${dispatch_reuse_headers}" "Idempotency-Key: demo-verify-k1" "${dispatch_url}")
461+
dispatch_reuse_http_code=$(printf '%s' "${dispatch_reuse_raw}" | sed -n 's/^__HTTP_CODE__://p' | tail -n 1)
462+
dispatch_reuse_response=$(printf '%s' "${dispatch_reuse_raw}" | sed '/^__HTTP_CODE__:/d')
463+
464+
dispatch_conflict_headers="${TMP_DIR}/dispatch-conflict.headers"
465+
dispatch_conflict_raw=$(mcp_post_with_token_optional_session "${probe_token}" "${dispatch_payload_b}" "${dispatch_conflict_headers}" "Idempotency-Key: demo-verify-k1" "${dispatch_url}")
466+
dispatch_conflict_http_code=$(printf '%s' "${dispatch_conflict_raw}" | sed -n 's/^__HTTP_CODE__://p' | tail -n 1)
467+
dispatch_conflict_response=$(printf '%s' "${dispatch_conflict_raw}" | sed '/^__HTTP_CODE__:/d')
462468

463469
model_headers_file="${TMP_DIR}/model.headers"
464470
model_raw=$(mcp_post_with_token_optional_session "${probe_token}" "${model_get_user_payload}" "${model_headers_file}")
@@ -650,7 +656,11 @@ Response:
650656
${oversized_response}
651657
\`\`\`
652658
653-
### 10) Gate C negative probe: 409 idempotency conflict
659+
Result-size cap probe:
660+
- status: \`pending\`
661+
- reason: requires larger dataset or temporary per-server \`max_result_bytes\` override in demo runtime.
662+
663+
### 10) Gate C idempotency probes (reuse + conflict)
654664
655665
First dispatch HTTP: \`${dispatch_a_http_code}\`
656666
@@ -659,18 +669,26 @@ First dispatch response:
659669
${dispatch_a_response}
660670
\`\`\`
661671
662-
Conflicting dispatch HTTP: \`${dispatch_b_http_code}\`
672+
Reuse dispatch HTTP (same key + same payload): \`${dispatch_reuse_http_code}\`
673+
674+
Reuse dispatch response:
675+
\`\`\`json
676+
${dispatch_reuse_response}
677+
\`\`\`
678+
679+
Conflicting dispatch HTTP (same key + different payload): \`${dispatch_conflict_http_code}\`
663680
664681
Conflicting dispatch response:
665682
\`\`\`json
666-
${dispatch_b_response}
683+
${dispatch_conflict_response}
667684
\`\`\`
668685
669686
### 11) rate-limit probe: 429 with Retry-After
670687
671688
429 observed: \`${rate_limit_observed}\`
672689
Retry-After: \`${rate_retry_after}\`
673690
HTTP: \`${rate_limit_http_code}\`
691+
Rate-limit identity type: \`${rate_limit_identity_type}\`
674692
675693
Response:
676694
\`\`\`json

tests/Integration/RuntimeIntegrationHttpTest.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,14 @@ function issueHs256Jwt(array $payload, string $secret): string
628628
if (!in_array($dispatchA['status'], [200, 202], true)) {
629629
fail("{$label} dispatch first call expected 200/202, got {$dispatchA['status']}.");
630630
}
631+
$dispatchAJson = decodeJson($dispatchA['body']);
632+
if (($dispatchAJson['reused'] ?? null) !== false) {
633+
fail("{$label} dispatch first call must return reused=false.");
634+
}
635+
if (($dispatchAJson['idempotency_key'] ?? null) !== $key) {
636+
fail("{$label} dispatch first call missing idempotency_key.");
637+
}
638+
$dispatchATaskId = is_numeric($dispatchAJson['task_id'] ?? null) ? (int)$dispatchAJson['task_id'] : null;
631639

632640
$dispatchB = httpPostJson($dispatchUrl, [
633641
'jsonrpc' => '2.0',
@@ -642,6 +650,17 @@ function issueHs256Jwt(array $payload, string $secret): string
642650
if (!in_array($dispatchB['status'], [200, 202], true)) {
643651
fail("{$label} dispatch reuse expected 200/202, got {$dispatchB['status']}.");
644652
}
653+
$dispatchBJson = decodeJson($dispatchB['body']);
654+
if (($dispatchBJson['reused'] ?? null) !== true) {
655+
fail("{$label} dispatch reuse must return reused=true.");
656+
}
657+
if (($dispatchBJson['idempotency_key'] ?? null) !== $key) {
658+
fail("{$label} dispatch reuse missing idempotency_key.");
659+
}
660+
$dispatchBTaskId = is_numeric($dispatchBJson['task_id'] ?? null) ? (int)$dispatchBJson['task_id'] : null;
661+
if ($dispatchATaskId !== null && $dispatchATaskId > 0 && $dispatchBTaskId !== $dispatchATaskId) {
662+
fail("{$label} dispatch reuse expected same task_id={$dispatchATaskId}, got " . (string)($dispatchBJson['task_id'] ?? 'null') . '.');
663+
}
645664

646665
$dispatchC = httpPostJson($dispatchUrl, [
647666
'jsonrpc' => '2.0',
@@ -656,6 +675,14 @@ function issueHs256Jwt(array $payload, string $secret): string
656675
if ($dispatchC['status'] !== 409) {
657676
fail("{$label} dispatch conflict expected 409, got {$dispatchC['status']}.");
658677
}
678+
$dispatchCJson = decodeJson($dispatchC['body']);
679+
if (($dispatchCJson['error']['code'] ?? null) !== 'idempotency_conflict') {
680+
fail("{$label} dispatch conflict expected error.code=idempotency_conflict.");
681+
}
682+
$traceId = trim((string)($dispatchCJson['error']['trace_id'] ?? ''));
683+
if ($traceId === '') {
684+
fail("{$label} dispatch conflict missing error.trace_id.");
685+
}
659686
}
660687

661688
info("{$label} checks passed.");

0 commit comments

Comments
 (0)