ci: align release with validated production bridge

This commit is contained in:
Haitao Pan 2026-04-12 17:42:10 +08:00
parent ab7c26901e
commit b8b1d83802
3 changed files with 111 additions and 4 deletions

View File

@ -195,8 +195,11 @@ jobs:
publish_release:
name: Publish GitHub Release
needs: build
if: ${{ github.event_name != 'pull_request' }}
needs:
- build
- deploy
- validate
if: ${{ github.event_name != 'pull_request' && needs.deploy.result == 'success' && needs.validate.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write

View File

@ -45,6 +45,10 @@ This repository includes one GitHub Actions pipeline with four stages:
- `deploy`: run Ansible CD with `x-evor/playbooks`
- `validate`: verify the public endpoints after deployment
GitHub Releases are published only after `deploy` and `validate` both succeed.
In this repository, a published Release means the built image has been deployed
to `xworkmate-bridge.svc.plus` and passed post-deploy validation there.
### Deploy stage
The deploy stage checks out:
@ -54,6 +58,16 @@ The deploy stage checks out:
Then it runs `playbooks/deploy_xworkmate_bridge_vhosts.yml`, which builds the service for `linux/amd64` and deploys it to the target host with Ansible.
### Validate stage
The validate stage proves production alignment against the bridge public
contract:
- bridge root and `/api/ping`
- strict image / tag / commit / version match against the built image ref
- upstream ACP capability probes for `codex`, `opencode`, and `gemini`
- minimal `session.start` smoke tests through `https://xworkmate-bridge.svc.plus/acp/rpc`
Required GitHub secrets:
- `SINGLE_NODE_VPS_SSH_PRIVATE_KEY`: private key used by the Actions runner to SSH into the target host

View File

@ -73,6 +73,7 @@ probe_jsonrpc_capabilities() {
local response
local headers=(
-H 'Content-Type: application/json'
-H 'Accept: application/json'
)
headers+=("${auth_headers[@]}")
@ -84,8 +85,94 @@ probe_jsonrpc_capabilities() {
"${endpoint}"
)"
grep -q '"jsonrpc":"2.0"' <<<"${response}"
grep -Eq '"result"|"providers"' <<<"${response}"
RESPONSE_JSON="${response}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
if payload.get("jsonrpc") != "2.0":
raise SystemExit("capabilities response missing jsonrpc envelope")
result = payload.get("result")
if not isinstance(result, dict):
raise SystemExit("capabilities response missing result payload")
if not result and "providers" not in payload:
raise SystemExit("capabilities response missing result/providers data")
PY
}
jsonrpc_bridge_call() {
local payload="$1"
local response
local headers=(
-H 'Content-Type: application/json'
-H 'Accept: application/json'
)
headers+=("${auth_headers[@]}")
response="$(
curl "${curl_common[@]}" \
"${headers[@]}" \
--data "${payload}" \
"${BASE_URL}/acp/rpc"
)"
printf '%s\n' "${response}"
}
probe_bridge_single_agent_smoke() {
local provider_id="$1"
local request_id="smoke-${provider_id}-$(date +%s)"
local session_id="validate-${provider_id}-$(date +%s)"
local payload
local response
payload="$(cat <<JSON
{"jsonrpc":"2.0","id":"${request_id}","method":"session.start","params":{"sessionId":"${session_id}","threadId":"${session_id}","taskPrompt":"Reply with exactly pong","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"${provider_id}"}}}
JSON
)"
response="$(jsonrpc_bridge_call "${payload}")"
PROVIDER_ID="${provider_id}" RESPONSE_JSON="${response}" python3 - <<'PY'
import json
import os
provider = os.environ["PROVIDER_ID"]
payload = json.loads(os.environ["RESPONSE_JSON"])
if payload.get("jsonrpc") != "2.0":
raise SystemExit(f"{provider}: missing jsonrpc envelope")
if payload.get("error"):
raise SystemExit(f"{provider}: rpc error {payload['error']}")
result = payload.get("result")
if not isinstance(result, dict):
raise SystemExit(f"{provider}: missing result payload")
if result.get("success") is not True:
raise SystemExit(f"{provider}: success flag was not true: {result!r}")
def first_text_candidate(data):
for key in ("output", "resultSummary", "summary", "message"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value
return ""
def normalize_text(value):
normalized = value.strip().strip("`").strip()
if len(normalized) >= 2 and normalized[0] == normalized[-1] and normalized[0] in {'"', "'"}:
normalized = normalized[1:-1].strip()
return normalized.lower()
text = first_text_candidate(result)
if normalize_text(text) != "pong":
raise SystemExit(f"{provider}: expected normalized pong output, got {text!r} from {result!r}")
PY
}
probe_safe_http_endpoint() {
@ -152,3 +239,6 @@ probe_safe_http_endpoint "${OPENCLAW_HTTP_PROBE_URL}"
probe_jsonrpc_capabilities "${CODEX_RPC_URL}"
probe_jsonrpc_capabilities "${OPENCODE_RPC_URL}"
probe_jsonrpc_capabilities "${GEMINI_RPC_URL}"
probe_bridge_single_agent_smoke "codex"
probe_bridge_single_agent_smoke "opencode"
probe_bridge_single_agent_smoke "gemini"