Add GitHub Actions pipeline for bridge deploy

This commit is contained in:
Haitao Pan 2026-04-10 16:17:32 +08:00
parent cb3be97ac6
commit 67696beab8
22 changed files with 394 additions and 46 deletions

View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
import base64
import os
import sys
def strip_outer_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1].strip()
return value
def raw_payload() -> str:
return strip_outer_quotes(os.environ["SINGLE_NODE_VPS_SSH_PRIVATE_KEY"].replace("\r", "").strip())
def normalize() -> str:
raw = raw_payload()
candidates = [raw]
if "\\n" in raw:
candidates.append(strip_outer_quotes(raw.replace("\\n", "\n").strip()))
try:
decoded = base64.b64decode(raw, validate=True).decode("utf-8").replace("\r", "").strip()
except Exception:
decoded = ""
if decoded:
candidates.append(strip_outer_quotes(decoded))
for candidate in candidates:
if "BEGIN " in candidate and "PRIVATE KEY" in candidate:
return candidate.rstrip("\n") + "\n"
return raw.rstrip("\n") + "\n"
def main() -> None:
if len(sys.argv) != 2 or sys.argv[1] != "normalize":
raise SystemExit("usage: normalize-private-key.py normalize")
sys.stdout.write(normalize())
if __name__ == "__main__":
main()

163
.github/workflows/pipeline.yml vendored Normal file
View File

@ -0,0 +1,163 @@
name: Pipeline
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
inputs:
target_host:
description: "Ansible inventory host or alias"
required: false
default: "jp-xhttp-contabo.svc.plus"
type: string
run_apply:
description: "Apply deployment (false = dry-run)"
required: true
default: true
type: boolean
internal_service_token:
description: "Optional ACP auth token for deploy"
required: false
default: ""
type: string
permissions:
contents: read
concurrency:
group: pipeline-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
env:
DEFAULT_TARGET_HOST: jp-xhttp-contabo.svc.plus
jobs:
prep:
name: Prep
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Set up golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64.8
- name: Run Go static checks
run: bash ./scripts/github-actions/prep.sh
build:
name: Build
needs: prep
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build x86 artifact
run: bash ./scripts/github-actions/build-artifact.sh dist
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: xworkmate-bridge-linux-amd64
path: dist/xworkmate-bridge
deploy:
name: Deploy
needs: build
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
outputs:
run_apply: ${{ steps.deploy_meta.outputs.run_apply }}
env:
INTERNAL_SERVICE_TOKEN: ${{ github.event_name == 'workflow_dispatch' && inputs.internal_service_token || secrets.INTERNAL_SERVICE_TOKEN }}
steps:
- name: Checkout service repository
uses: actions/checkout@v4
with:
path: xworkmate-bridge
- name: Checkout playbooks repository
uses: actions/checkout@v4
with:
repository: x-evor/playbooks
token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }}
path: playbooks
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: xworkmate-bridge/go.mod
cache: true
cache-dependency-path: xworkmate-bridge/go.sum
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Ansible runtime
run: |
python -m pip install --upgrade pip
python -m pip install "ansible-core==2.18.3"
- name: Resolve deployment settings
id: deploy_meta
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
target_host="${{ inputs.target_host }}"
run_apply="${{ inputs.run_apply }}"
else
target_host="${DEFAULT_TARGET_HOST}"
run_apply="true"
fi
if [[ -z "${target_host}" ]]; then
target_host="${DEFAULT_TARGET_HOST}"
fi
echo "target_host=${target_host}" >> "$GITHUB_OUTPUT"
echo "run_apply=${run_apply}" >> "$GITHUB_OUTPUT"
- name: Prepare runner SSH access
working-directory: xworkmate-bridge
env:
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: bash ./scripts/github-actions/prepare-ssh.sh "${{ steps.deploy_meta.outputs.target_host }}" "${SSH_KNOWN_HOSTS}"
- name: Run Ansible deploy playbook
working-directory: xworkmate-bridge
run: bash ./scripts/github-actions/deploy.sh "${{ steps.deploy_meta.outputs.target_host }}" "${{ steps.deploy_meta.outputs.run_apply }}" ../playbooks
validate:
name: Validate
needs: deploy
if: ${{ needs.deploy.result == 'success' && needs.deploy.outputs.run_apply == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate deployed endpoints
run: bash ./scripts/github-actions/validate-deploy.sh

View File

@ -29,6 +29,37 @@ make build
./build/bin/xworkmate-go-core serve --listen 127.0.0.1:8787
```
## GitHub Actions
This repository includes one GitHub Actions pipeline with four stages:
- `prep`: Go static checks
- `build`: build the `linux/amd64` artifact for the x86 target host and upload it
- `deploy`: run Ansible CD with `x-evor/playbooks`
- `validate`: verify the public endpoints after deployment
### Deploy stage
The deploy stage checks out:
- this service repository into `xworkmate-bridge/`
- the `x-evor/playbooks` repository into `playbooks/`
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.
Required GitHub secrets:
- `SINGLE_NODE_VPS_SSH_PRIVATE_KEY`: private key used by the Actions runner to SSH into the target host
- `WORKSPACE_REPO_TOKEN`: token with access to checkout `x-evor/playbooks`
Optional GitHub secrets:
- `SSH_KNOWN_HOSTS`: pre-seeded known_hosts content for stricter host verification
Optional workflow input:
- `internal_service_token`: manual dispatch input that is forwarded to Ansible as `INTERNAL_SERVICE_TOKEN`
## Environment
- `ACP_LISTEN_ADDR`: listen address for `serve` mode, default `127.0.0.1:8787`

View File

@ -124,7 +124,9 @@ func consumeBootstrapFromAccounts(req bridgeBootstrapConsumeRequest) (accountsBr
if err != nil {
return accountsBridgeBootstrapConsumeResponse{}, http.StatusBadGateway, fmt.Errorf("failed to contact accounts service")
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return accountsBridgeBootstrapConsumeResponse{}, resp.StatusCode, fmt.Errorf("accounts bootstrap consume failed")
}

View File

@ -301,7 +301,9 @@ func requestExternalACPHTTP(
if err != nil {
return nil, err
}
defer response.Body.Close()
defer func() {
_ = response.Body.Close()
}()
var decoded map[string]any
if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
return nil, err
@ -594,7 +596,9 @@ func requestExternalACPWebSocket(
if err != nil {
return nil, err
}
defer conn.Close()
defer func() {
_ = conn.Close()
}()
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano())
if err := conn.WriteJSON(map[string]any{
@ -679,9 +683,10 @@ func (u *urlSpec) basePath() string {
func (u *urlSpec) httpRPCEndpoint() string {
scheme := u.Scheme
if scheme == "ws" {
switch scheme {
case "ws":
scheme = "http"
} else if scheme == "wss" {
case "wss":
scheme = "https"
}
basePath := u.basePath()
@ -695,9 +700,10 @@ func (u *urlSpec) httpRPCEndpoint() string {
func (u *urlSpec) webSocketEndpoint() string {
scheme := u.Scheme
if scheme == "http" {
switch scheme {
case "http":
scheme = "ws"
} else if scheme == "https" {
case "https":
scheme = "wss"
}
basePath := u.basePath()

View File

@ -96,7 +96,9 @@ func TestExecuteSessionTaskUsesSyncedExternalProvider(t *testing.T) {
http.NotFound(w, r)
return
}
defer r.Body.Close()
defer func() {
_ = r.Body.Close()
}()
var request map[string]any
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
@ -188,7 +190,9 @@ func TestExecuteSessionTaskEnrichesExternalProviderResultWithArtifactsAndRemoteM
http.NotFound(w, r)
return
}
defer r.Body.Close()
defer func() {
_ = r.Body.Close()
}()
var request map[string]any
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
@ -274,7 +278,9 @@ func TestRunSingleAgentUsesFrozenExternalProviderParams(t *testing.T) {
http.NotFound(w, r)
return
}
defer r.Body.Close()
defer func() {
_ = r.Body.Close()
}()
var request map[string]any
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)

View File

@ -15,10 +15,6 @@ func handleRoutingResolve(params map[string]any) map[string]any {
return mergeRoutingResponse(map[string]any{"ok": true}, result)
}
func resolveRoutingMetadata(params map[string]any) (router.Result, bool) {
return resolveRoutingMetadataWithProviders(params, nil)
}
func resolveRoutingMetadataWithProviders(
params map[string]any,
availableProviders []string,

View File

@ -23,7 +23,9 @@ func newExternalSingleAgentProvider(
http.NotFound(w, r)
return
}
defer r.Body.Close()
defer func() {
_ = r.Body.Close()
}()
var request map[string]any
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)

View File

@ -142,7 +142,9 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
if err != nil {
return
}
defer conn.Close()
defer func() {
_ = conn.Close()
}()
var writeMu sync.Mutex
notify := func(message map[string]any) {

View File

@ -198,7 +198,9 @@ func newFakeGatewayServer(t *testing.T) *fakeGatewayServer {
if err != nil {
return
}
defer conn.Close()
defer func() {
_ = conn.Close()
}()
_ = conn.WriteJSON(map[string]any{
"type": "event",
"event": "connect.challenge",

View File

@ -77,7 +77,9 @@ func Serve(args []string) error {
nil,
shared.IntArg(shared.EnvOrDefault("GEMINI_ADAPTER_PROTOCOL_VERSION", "1"), 1),
)
defer client.Close()
defer func() {
_ = client.Close()
}()
server := NewServer(client)
httpServer := &http.Server{
@ -140,7 +142,9 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
if err != nil {
return
}
defer conn.Close()
defer func() {
_ = conn.Close()
}()
var writeMu sync.Mutex
notify := func(message map[string]any) {

View File

@ -227,7 +227,9 @@ func TestHandleWebSocketCapabilities(t *testing.T) {
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer conn.Close()
defer func() {
_ = conn.Close()
}()
if err := conn.WriteJSON(shared.RPCRequest{
JSONRPC: "2.0",

View File

@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"net/http"
@ -45,5 +46,5 @@ type authServiceAdapter struct {
}
func (a authServiceAdapter) Authenticate(username, password string) error {
return a.service.Authenticate(nil, username, password)
return a.service.Authenticate(context.TODO(), username, password)
}

View File

@ -85,14 +85,12 @@ func buildCodexManagedMCPBlock(servers []ManagedMCPServer) string {
var buffer strings.Builder
buffer.WriteString(codexManagedMCPBlockStart)
buffer.WriteString("\n# Generated by XWorkmate - Managed MCP Server Configuration\n")
buffer.WriteString(
fmt.Sprintf("# Last updated: %s\n\n", time.Now().Format(time.RFC3339Nano)),
)
_, _ = fmt.Fprintf(&buffer, "# Last updated: %s\n\n", time.Now().Format(time.RFC3339Nano))
for _, server := range servers {
buffer.WriteString(fmt.Sprintf("[mcp_servers.%s]\n", server.ID))
buffer.WriteString(fmt.Sprintf("command = %q\n", server.Command))
_, _ = fmt.Fprintf(&buffer, "[mcp_servers.%s]\n", server.ID)
_, _ = fmt.Fprintf(&buffer, "command = %q\n", server.Command)
if len(server.Args) > 0 {
buffer.WriteString(fmt.Sprintf("args = %s\n", formatTOMLArray(server.Args)))
_, _ = fmt.Fprintf(&buffer, "args = %s\n", formatTOMLArray(server.Args))
}
buffer.WriteString("\n")
}
@ -104,18 +102,16 @@ func buildOpencodeManagedMCPBlock(servers []ManagedMCPServer) string {
var buffer strings.Builder
buffer.WriteString(opencodeManagedMCPBlockStart)
buffer.WriteString("\n# Generated by XWorkmate - Managed MCP Server Configuration\n")
buffer.WriteString(
fmt.Sprintf("# Last updated: %s\n\n", time.Now().Format(time.RFC3339Nano)),
)
_, _ = fmt.Fprintf(&buffer, "# Last updated: %s\n\n", time.Now().Format(time.RFC3339Nano))
for _, server := range servers {
buffer.WriteString(fmt.Sprintf("[mcp_servers.%s]\n", server.ID))
_, _ = fmt.Fprintf(&buffer, "[mcp_servers.%s]\n", server.ID)
if strings.TrimSpace(server.URL) != "" {
buffer.WriteString(fmt.Sprintf("url = %q\n", strings.TrimSpace(server.URL)))
_, _ = fmt.Fprintf(&buffer, "url = %q\n", strings.TrimSpace(server.URL))
} else {
buffer.WriteString("type = \"stdio\"\n")
buffer.WriteString(fmt.Sprintf("command = %q\n", server.Command))
_, _ = fmt.Fprintf(&buffer, "command = %q\n", server.Command)
if len(server.Args) > 0 {
buffer.WriteString(fmt.Sprintf("args = %s\n", formatTOMLArray(server.Args)))
_, _ = fmt.Fprintf(&buffer, "args = %s\n", formatTOMLArray(server.Args))
}
}
buffer.WriteString("\n")

View File

@ -210,7 +210,7 @@ func ComposeHistoryPrompt(history []string) string {
}
var builder strings.Builder
for index, turn := range history {
builder.WriteString(fmt.Sprintf("## User Turn %d\n", index+1))
_, _ = fmt.Fprintf(&builder, "## User Turn %d\n", index+1)
builder.WriteString(turn)
builder.WriteString("\n\n")
}
@ -248,7 +248,9 @@ func CallOpenAICompatibleCtx(
if err != nil {
return "", err
}
defer response.Body.Close()
defer func() {
_ = response.Body.Close()
}()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return "", err
@ -356,7 +358,7 @@ func RunClaudeReview(
claudeBin := strings.TrimSpace(EnvOrDefault("CLAUDE_BIN", "claude"))
resolved, err := exec.LookPath(claudeBin)
if err != nil {
return "", fmt.Errorf("Claude CLI not found: %s", claudeBin)
return "", fmt.Errorf("claude CLI not found: %s", claudeBin)
}
args := []string{
@ -389,13 +391,13 @@ func RunClaudeReview(
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return "", fmt.Errorf("Claude review timed out after %s", timeout)
return "", fmt.Errorf("claude review timed out after %s", timeout)
}
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return "", fmt.Errorf("Claude review failed: %s", message)
return "", fmt.Errorf("claude review failed: %s", message)
}
payload, err := ParseClaudeJSON(stdout.String())
@ -411,7 +413,7 @@ func RunClaudeReview(
}
response := strings.TrimSpace(fmt.Sprint(payload["result"]))
if response == "" || response == "<nil>" {
return "", errors.New("Claude review returned empty output")
return "", errors.New("claude review returned empty output")
}
return response, nil
}
@ -428,5 +430,5 @@ func ParseClaudeJSON(raw string) (map[string]any, error) {
return payload, nil
}
}
return nil, errors.New("Claude CLI did not return JSON output")
return nil, errors.New("claude CLI did not return JSON output")
}

View File

@ -208,7 +208,9 @@ func doVaultRequest(
if err != nil {
return nil, err
}
defer response.Body.Close()
defer func() {
_ = response.Body.Close()
}()
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
return nil, err

View File

@ -23,10 +23,6 @@ func handleChatTool(arguments map[string]any) (string, error) {
return shared.HandleChatTool(arguments)
}
func handleVaultKVTool(arguments map[string]any) (string, error) {
return shared.HandleVaultKVTool(arguments)
}
func runClaudeReview(
prompt,
model,

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ARTIFACT_DIR="${1:-dist}"
mkdir -p "${ARTIFACT_DIR}"
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o "${ARTIFACT_DIR}/xworkmate-bridge" .

View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_HOST="${1:?target host is required}"
RUN_APPLY="${2:?run_apply flag is required}"
PLAYBOOK_DIR="${3:-playbooks}"
cd "${PLAYBOOK_DIR}"
args=(
ansible-playbook
-i inventory.ini
deploy_xworkmate_bridge_vhosts.yml
-l "${TARGET_HOST}"
)
if [[ "${RUN_APPLY}" != "true" ]]; then
args+=(-C)
fi
"${args[@]}"

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
go mod download
go mod verify
golangci-lint run ./...

View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_HOST="${1:?target host is required}"
SSH_KNOWN_HOSTS_PAYLOAD="${2:-}"
test -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}"
mkdir -p "${HOME}/.ssh"
chmod 700 "${HOME}/.ssh"
python3 .github/scripts/normalize-private-key.py normalize > "${HOME}/.ssh/id_rsa"
chmod 600 "${HOME}/.ssh/id_rsa"
ssh-keygen -y -f "${HOME}/.ssh/id_rsa" >/dev/null
touch "${HOME}/.ssh/known_hosts"
chmod 600 "${HOME}/.ssh/known_hosts"
if [[ -n "${SSH_KNOWN_HOSTS_PAYLOAD}" ]]; then
printf '%s\n' "${SSH_KNOWN_HOSTS_PAYLOAD}" >> "${HOME}/.ssh/known_hosts"
fi
ssh-keyscan -H "${TARGET_HOST}" >> "${HOME}/.ssh/known_hosts" 2>/dev/null || true

View File

@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-https://xworkmate-bridge.svc.plus}"
INGRESS_URL="${2:-https://acp-server.svc.plus}"
bridge_root="$(curl -fsS "${BASE_URL}/")"
test "${bridge_root}" = "xworkmate-bridge is running"
codex_root="$(curl -fsS "${INGRESS_URL}/codex")"
test "${codex_root}" = "xworkmate-bridge is running"
codex_rpc="$(
curl -sS "${INGRESS_URL}/codex/acp/rpc" \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"cap-1","method":"acp.capabilities"}'
)"
opencode_rpc="$(
curl -sS "${INGRESS_URL}/opencode/acp/rpc" \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"cap-1","method":"acp.capabilities"}'
)"
gemini_rpc="$(
curl -sS "${INGRESS_URL}/gemini/acp/rpc" \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"cap-1","method":"acp.capabilities"}'
)"
grep -q '"missing bearer authorization"' <<<"${codex_rpc}"
grep -q '"missing bearer authorization"' <<<"${opencode_rpc}"
grep -q '"providers"' <<<"${gemini_rpc}"