ci: ship bridge via image ref artifact

This commit is contained in:
Haitao Pan 2026-04-12 14:23:23 +08:00
parent 6314df67d5
commit 0fcaa845e1
8 changed files with 318 additions and 50 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.git
.github
dist
build
.worktrees

View File

@ -25,6 +25,7 @@ on:
permissions:
contents: read
packages: write
concurrency:
group: pipeline-${{ github.ref }}
@ -64,33 +65,67 @@ jobs:
name: Build
needs: prep
runs-on: ubuntu-latest
env:
SERVICE_REGISTRY: ghcr.io
SERVICE_IMAGE_REPO_OWNER: ${{ vars.IMAGE_REPO_OWNER || github.repository_owner }}
SERVICE_IMAGE_NAME: xworkmate-bridge
outputs:
artifact_name: ${{ steps.artifact_meta.outputs.artifact_name }}
service_image_ref: ${{ steps.service_ref.outputs.image_ref }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to GHCR
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
go-version-file: go.mod
cache: true
registry: ghcr.io
username: ${{ vars.GHCR_USERNAME || github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN || github.token }}
- name: Build x86 artifact
run: bash ./scripts/github-actions/build-artifact.sh dist
- name: Resolve service image ref
id: service_ref
run: |
set -euo pipefail
image_repo="${SERVICE_REGISTRY}/${SERVICE_IMAGE_REPO_OWNER}/${SERVICE_IMAGE_NAME}"
image_tag="${GITHUB_SHA}"
image_ref="${image_repo}:${image_tag}"
- name: Smoke test built artifact
run: ./dist/xworkmate-bridge --help >/dev/null
echo "image_repo=${image_repo}" >> "$GITHUB_OUTPUT"
echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT"
echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT"
- name: Build and optionally push service image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.service_ref.outputs.image_ref }}
- name: Write image ref artifact
run: |
set -euo pipefail
mkdir -p dist
printf '%s\n' "${{ steps.service_ref.outputs.image_ref }}" > dist/service-image-ref.txt
- name: Record artifact metadata
id: artifact_meta
run: echo "artifact_name=xworkmate-bridge-linux-amd64" >> "$GITHUB_OUTPUT"
run: echo "artifact_name=xworkmate-bridge-service-image-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Upload artifact
- name: Upload image ref artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.artifact_meta.outputs.artifact_name }}
path: dist/xworkmate-bridge
path: dist/service-image-ref.txt
deploy:
name: Deploy
@ -101,6 +136,8 @@ jobs:
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 }}
GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }}
GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN || github.token }}
steps:
- name: Checkout service repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -114,18 +151,11 @@ jobs:
token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }}
path: playbooks
- name: Download build artifact
- name: Download build image ref artifact
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
with:
name: ${{ needs.build.outputs.artifact_name }}
path: xworkmate-bridge/dist
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: xworkmate-bridge/go.mod
cache: true
cache-dependency-path: xworkmate-bridge/go.sum
path: xworkmate-bridge/dist/image-artifact
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@ -166,7 +196,9 @@ jobs:
working-directory: xworkmate-bridge
env:
INTERNAL_SERVICE_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
XWORKMATE_BRIDGE_ARTIFACT_PATH: ${{ github.workspace }}/xworkmate-bridge/dist/xworkmate-bridge
GHCR_USERNAME: ${{ env.GHCR_USERNAME }}
GHCR_PASSWORD: ${{ env.GHCR_PASSWORD }}
XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH: ${{ github.workspace }}/xworkmate-bridge/dist/image-artifact/service-image-ref.txt
run: bash ./scripts/github-actions/deploy.sh "${{ steps.deploy_meta.outputs.target_host }}" "${{ steps.deploy_meta.outputs.run_apply }}" ../playbooks
publish_release:
@ -177,7 +209,7 @@ jobs:
permissions:
contents: write
steps:
- name: Download build artifact
- name: Download image ref artifact
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
with:
name: ${{ needs.build.outputs.artifact_name }}
@ -198,7 +230,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh release create "${{ steps.release_meta.outputs.tag }}" \
dist/xworkmate-bridge#xworkmate-bridge-linux-amd64 \
dist/service-image-ref.txt#xworkmate-bridge-service-image-ref.txt \
--repo "${{ github.repository }}" \
--target "${{ github.sha }}" \
--title "${{ steps.release_meta.outputs.title }}" \
@ -206,7 +238,9 @@ jobs:
validate:
name: Validate
needs: deploy
needs:
- build
- deploy
if: ${{ needs.deploy.result == 'success' && needs.deploy.outputs.run_apply == 'true' }}
runs-on: ubuntu-latest
env:
@ -221,4 +255,4 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate deployed endpoints
run: bash ./scripts/github-actions/validate-deploy.sh
run: bash ./scripts/github-actions/validate-deploy.sh "${{ needs.build.outputs.service_image_ref }}" "${BRIDGE_SERVER_URL}" "${OPENCLAW_URL}" "${CODEX_RPC_URL}" "${OPENCODE_RPC_URL}" "${GEMINI_RPC_URL}" "${INTERNAL_SERVICE_TOKEN}"

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# Stage 1 - build the bridge binary
FROM golang:1.25.1 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/xworkmate-bridge .
# Stage 2 - minimal runtime image
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/xworkmate-bridge /usr/local/bin/xworkmate-bridge
EXPOSE 8787
ENTRYPOINT ["/usr/local/bin/xworkmate-bridge", "serve", "--listen", "0.0.0.0:8787"]

View File

@ -0,0 +1,54 @@
package acp
import "strings"
type imageVersionInfo struct {
ImageRef string `json:"image_ref"`
Tag string `json:"tag,omitempty"`
Commit string `json:"commit,omitempty"`
Version string `json:"version,omitempty"`
}
func parseImageVersionInfo(imageRef string) imageVersionInfo {
ref := strings.TrimSpace(imageRef)
info := imageVersionInfo{ImageRef: ref}
if ref == "" {
return info
}
if idx := strings.LastIndex(ref, "@"); idx >= 0 {
ref = ref[:idx]
}
tag := ref
if idx := strings.LastIndex(tag, ":"); idx >= 0 && idx > strings.LastIndex(tag, "/") {
tag = tag[idx+1:]
}
tag = strings.TrimSpace(tag)
info.Tag = tag
switch {
case isHexCommit(tag):
info.Commit = tag
info.Version = tag
default:
info.Version = tag
}
return info
}
func isHexCommit(value string) bool {
if len(value) != 40 {
return false
}
for _, r := range value {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
default:
return false
}
}
return true
}

View File

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
@ -74,23 +75,7 @@ func Serve(args []string) error {
server := NewServer()
httpServer := &http.Server{
Addr: strings.TrimSpace(*listen),
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("xworkmate-bridge is running"))
case "/bridge/bootstrap/health":
server.HandleBridgeBootstrapHealth(w, r)
case "/bridge/bootstrap/consume":
server.HandleBridgeBootstrapConsume(w, r)
case "/acp/rpc":
server.HandleRPC(w, r)
case "/acp":
server.HandleWebSocket(w, r)
default:
http.NotFound(w, r)
}
}),
Handler: server.Handler(),
ReadTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
@ -115,6 +100,37 @@ func NewServer() *Server {
}
}
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("xworkmate-bridge is running"))
case "/api/ping":
info := parseImageVersionInfo(os.Getenv("IMAGE"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"image": info.ImageRef,
"tag": info.Tag,
"commit": info.Commit,
"version": info.Version,
})
case "/bridge/bootstrap/health":
s.HandleBridgeBootstrapHealth(w, r)
case "/bridge/bootstrap/consume":
s.HandleBridgeBootstrapConsume(w, r)
case "/acp/rpc":
s.HandleRPC(w, r)
case "/acp":
s.HandleWebSocket(w, r)
default:
http.NotFound(w, r)
}
})
}
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if !s.originAllowed(origin) {

View File

@ -8,6 +8,70 @@ import (
"testing"
)
func TestHTTPHandlerRootAndPingExposeRuntimeVersionInfo(t *testing.T) {
t.Setenv("IMAGE", "ghcr.io/x-evor/xworkmate-bridge:0123456789abcdef0123456789abcdef01234567")
server := NewServer()
handler := server.Handler()
rootRecorder := httptest.NewRecorder()
rootRequest := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/", nil)
handler.ServeHTTP(rootRecorder, rootRequest)
if rootRecorder.Code != http.StatusOK {
t.Fatalf("expected root 200, got %d", rootRecorder.Code)
}
if !strings.Contains(rootRecorder.Body.String(), "xworkmate-bridge is running") {
t.Fatalf("expected root body to contain service banner, got %q", rootRecorder.Body.String())
}
pingRecorder := httptest.NewRecorder()
pingRequest := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/ping", nil)
handler.ServeHTTP(pingRecorder, pingRequest)
if pingRecorder.Code != http.StatusOK {
t.Fatalf("expected ping 200, got %d", pingRecorder.Code)
}
var payload map[string]any
if err := json.Unmarshal(pingRecorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode ping payload: %v", err)
}
if got := payload["status"]; got != "ok" {
t.Fatalf("expected status ok, got %#v", got)
}
if got := payload["image"]; got != "ghcr.io/x-evor/xworkmate-bridge:0123456789abcdef0123456789abcdef01234567" {
t.Fatalf("expected full image ref, got %#v", got)
}
if got := payload["tag"]; got != "0123456789abcdef0123456789abcdef01234567" {
t.Fatalf("expected full image tag, got %#v", got)
}
if got := payload["commit"]; got != "0123456789abcdef0123456789abcdef01234567" {
t.Fatalf("expected full image commit, got %#v", got)
}
if got := payload["version"]; got != "0123456789abcdef0123456789abcdef01234567" {
t.Fatalf("expected full image version, got %#v", got)
}
}
func TestParseImageVersionInfoHandlesTaggedImageRef(t *testing.T) {
info := parseImageVersionInfo("ghcr.io/x-evor/xworkmate-bridge:main-2026-04-12")
if info.ImageRef != "ghcr.io/x-evor/xworkmate-bridge:main-2026-04-12" {
t.Fatalf("expected full image ref, got %q", info.ImageRef)
}
if info.Tag != "main-2026-04-12" {
t.Fatalf("expected tag main-2026-04-12, got %q", info.Tag)
}
if info.Commit != "" {
t.Fatalf("expected empty commit for non-hex tag, got %q", info.Commit)
}
if info.Version != "main-2026-04-12" {
t.Fatalf("expected version main-2026-04-12, got %q", info.Version)
}
}
func TestHandleWebSocketRejectsUnknownOrigin(t *testing.T) {
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus")

View File

@ -4,7 +4,25 @@ set -euo pipefail
TARGET_HOST="${1:?target host is required}"
RUN_APPLY="${2:?run_apply flag is required}"
PLAYBOOK_DIR="${3:-playbooks}"
XWORKMATE_BRIDGE_ARTIFACT_PATH="${XWORKMATE_BRIDGE_ARTIFACT_PATH:?artifact path is required}"
XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH="${XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH:?image artifact path is required}"
if [[ ! -f "${XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH}" ]]; then
echo "image artifact not found at ${XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH}" >&2
exit 1
fi
SERVICE_COMPOSE_IMAGE="$(tr -d '\n' < "${XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH}" | xargs)"
if [[ -z "${SERVICE_COMPOSE_IMAGE}" ]]; then
echo "service compose image is empty" >&2
exit 1
fi
image_no_digest="${SERVICE_COMPOSE_IMAGE%@*}"
image_tag="${image_no_digest##*:}"
if [[ -z "${image_tag}" || "${image_no_digest}" == "${image_tag}" ]]; then
echo "invalid service image ref: ${SERVICE_COMPOSE_IMAGE}" >&2
exit 1
fi
cd "${PLAYBOOK_DIR}"
@ -13,7 +31,6 @@ args=(
-i inventory.ini
deploy_xworkmate_bridge_vhosts.yml
-l "${TARGET_HOST}"
-e "xworkmate_bridge_artifact_path=${XWORKMATE_BRIDGE_ARTIFACT_PATH}"
)
if [[ "${RUN_APPLY}" != "true" ]]; then
@ -21,4 +38,7 @@ if [[ "${RUN_APPLY}" != "true" ]]; then
fi
ANSIBLE_CONFIG="${PWD}/ansible.cfg" \
SERVICE_COMPOSE_IMAGE="${SERVICE_COMPOSE_IMAGE}" \
GHCR_USERNAME="${GHCR_USERNAME:-}" \
GHCR_PASSWORD="${GHCR_PASSWORD:-}" \
"${args[@]}"

View File

@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_REF="${1:?image_ref is required}"
normalize_url() {
local value="$1"
if [[ "${value}" =~ ^https:([^/].*)$ ]]; then
@ -27,12 +29,31 @@ websocket_probe_url() {
printf '%s\n' "${value}"
}
BASE_URL="$(normalize_url "${BRIDGE_SERVER_URL:-${1:-https://xworkmate-bridge.svc.plus}}")"
OPENCLAW_HTTP_PROBE_URL="$(websocket_probe_url "${OPENCLAW_URL:-${2:-wss://openclaw.svc.plus}}")"
CODEX_RPC_URL="$(normalize_url "${CODEX_RPC_URL:-${3:-https://acp-server.svc.plus/codex/acp/rpc}}")"
OPENCODE_RPC_URL="$(normalize_url "${OPENCODE_RPC_URL:-${4:-https://acp-server.svc.plus/opencode/acp/rpc}}")"
GEMINI_RPC_URL="$(normalize_url "${GEMINI_RPC_URL:-${5:-https://acp-server.svc.plus/gemini/acp/rpc}}")"
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-${INTERNAL_SERVICE_TOKEN:-${6:-}}}"
image_ref="$(printf '%s' "${IMAGE_REF}" | tr -d '\n' | xargs)"
if [[ -z "${image_ref}" ]]; then
echo "image_ref is required" >&2
exit 1
fi
image_no_digest="${image_ref%@*}"
tag="${image_no_digest##*:}"
if [[ "${image_no_digest}" == "${tag}" ]]; then
tag=""
fi
commit=""
version="${tag}"
if [[ "${tag}" =~ ^[0-9a-f]{40}$ ]]; then
commit="${tag}"
fi
BASE_URL="$(normalize_url "${BRIDGE_SERVER_URL:-${2:-https://xworkmate-bridge.svc.plus}}")"
OPENCLAW_HTTP_PROBE_URL="$(websocket_probe_url "${OPENCLAW_URL:-${3:-wss://openclaw.svc.plus}}")"
CODEX_RPC_URL="$(normalize_url "${CODEX_RPC_URL:-${4:-https://acp-server.svc.plus/codex/acp/rpc}}")"
OPENCODE_RPC_URL="$(normalize_url "${OPENCODE_RPC_URL:-${5:-https://acp-server.svc.plus/opencode/acp/rpc}}")"
GEMINI_RPC_URL="$(normalize_url "${GEMINI_RPC_URL:-${6:-https://acp-server.svc.plus/gemini/acp/rpc}}")"
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-${INTERNAL_SERVICE_TOKEN:-${7:-}}}"
curl_common=(
--silent
@ -93,6 +114,36 @@ probe_safe_http_endpoint() {
esac
}
ping_json="$(
curl \
"${curl_common[@]}" \
"${BASE_URL}/api/ping"
)"
PING_JSON="${ping_json}" python3 - "${image_ref}" "${tag}" "${commit}" "${version}" <<'PY'
import json
import os
import sys
image_ref, tag, commit, version = sys.argv[1:5]
payload = json.loads(os.environ["PING_JSON"])
if payload.get("status") != "ok":
raise SystemExit("ping status not ok")
if payload.get("image") != image_ref:
raise SystemExit(f"expected image {image_ref!r}, got {payload.get('image')!r}")
if tag and payload.get("tag") != tag:
raise SystemExit(f"expected tag {tag!r}, got {payload.get('tag')!r}")
if commit and payload.get("commit") != commit:
raise SystemExit(f"expected commit {commit!r}, got {payload.get('commit')!r}")
if version and payload.get("version") != version:
raise SystemExit(f"expected version {version!r}, got {payload.get('version')!r}")
PY
bridge_root="$(curl "${curl_common[@]}" "${auth_headers[@]}" "${BASE_URL}/")"
grep -qi 'xworkmate-bridge' <<<"${bridge_root}"