From 9b652fea6736051d512236b5a88de7749e292650 Mon Sep 17 00:00:00 2001 From: coolxll Date: Mon, 30 Mar 2026 15:57:18 +0800 Subject: [PATCH] ci: add GitHub release workflow --- .github/workflows/release.yml | 72 +++++++++++++++++ README.md | 21 +++++ README.zh-CN.md | 21 +++++ internal/httpapi/server_test.go | 137 +++++++++++++++++--------------- 4 files changed, 185 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f69abf3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Release tag, for example v0.1.0" + required: true + +permissions: + contents: write + +jobs: + test: + name: Test + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... + + build-release: + name: Build And Publish Release + runs-on: windows-latest + needs: test + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + ARCHIVE_NAME: lingma-ipc-proxy_${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}_windows_amd64.zip + CHECKSUM_NAME: lingma-ipc-proxy_${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}_sha256.txt + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build binary + shell: pwsh + run: .\scripts\build.ps1 -Clean + + - name: Prepare release assets + shell: pwsh + run: | + $archivePath = Join-Path $PWD $env:ARCHIVE_NAME + $checksumPath = Join-Path $PWD $env:CHECKSUM_NAME + Compress-Archive -Path .\dist\lingma-ipc-proxy.exe -DestinationPath $archivePath -Force + + $hash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() + "$hash $($env:ARCHIVE_NAME)" | Set-Content -NoNewline $checksumPath + + - name: Create or update release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + generate_release_notes: true + files: | + ${{ env.ARCHIVE_NAME }} + ${{ env.CHECKSUM_NAME }} diff --git a/README.md b/README.md index fa7a17f..f5b6f10 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,27 @@ Default output: dist\lingma-ipc-proxy.exe ``` +## Release + +GitHub Actions can publish a GitHub Release automatically. + +Trigger rules: + +- push a tag matching `v*`, for example `v0.1.0` +- or run the `Release` workflow manually and pass a tag + +Example: + +```powershell +git tag v0.1.0 +git push origin v0.1.0 +``` + +Release assets: + +- `lingma-ipc-proxy__windows_amd64.zip` +- `lingma-ipc-proxy__sha256.txt` + Direct Go build command: ```powershell diff --git a/README.zh-CN.md b/README.zh-CN.md index 67ab8cf..f7afb2b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,6 +84,27 @@ cd C:\Workspace\Personal\lingma-ipc-proxy dist\lingma-ipc-proxy.exe ``` +## 发布 + +GitHub Actions 可以自动发布 GitHub Release。 + +触发方式: + +- 推送匹配 `v*` 的 tag,例如 `v0.1.0` +- 或手动运行 `Release` workflow,并传入一个 tag + +示例: + +```powershell +git tag v0.1.0 +git push origin v0.1.0 +``` + +发布产物: + +- `lingma-ipc-proxy__windows_amd64.zip` +- `lingma-ipc-proxy__sha256.txt` + 等价的 Go 构建命令: ```powershell diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 1d0bccc..708dbe9 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -1,114 +1,119 @@ package httpapi -import ( - "strings" - "testing" -) +import "testing" -func TestNormalizeOpenAIRequestKeepsEmulationForToolHistoryWithoutTools(t *testing.T) { - s := &Server{cfg: Config{EmulateToolCalls: true}} +func TestNormalizeOpenAIRequestCollectsSystemMessages(t *testing.T) { req := openAIChatRequest{ Model: "test-model", Messages: []rawMessage{ - { - Role: "user", - Content: "Call ping once, then after the tool result reply FINAL_OK.", - }, - { - Role: "assistant", - Content: nil, - ToolCalls: []rawToolCall{ - { - ID: "call_1", - Type: "function", - Function: struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - }{ - Name: "ping", - Arguments: "{\"value\":\"x\"}", - }, - }, - }, - }, - { - Role: "tool", - ToolCallID: "call_1", - Content: "pong", - }, + {Role: "system", Content: "You are concise."}, + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi"}, + {Role: "system", Content: "Answer in Chinese."}, + {Role: "tool", Content: "ignored"}, + {Role: "user", Content: []any{ + map[string]any{"type": "text", "text": "Follow up"}, + }}, }, } - normalized, err := s.normalizeOpenAIRequest(req) + normalized, err := normalizeOpenAIRequest(req) if err != nil { t.Fatalf("normalizeOpenAIRequest() error = %v", err) } - if !normalized.Emulated { - t.Fatalf("expected emulation to stay enabled when tool history exists") + if normalized.Model != "test-model" { + t.Fatalf("model = %q", normalized.Model) } - if len(normalized.ChatRequest.Messages) != 3 { - t.Fatalf("message count = %d", len(normalized.ChatRequest.Messages)) + if normalized.System != "You are concise.\n\nAnswer in Chinese." { + t.Fatalf("system = %q", normalized.System) } - if normalized.ChatRequest.Messages[1].Role != "assistant" { - t.Fatalf("assistant message role = %q", normalized.ChatRequest.Messages[1].Role) + if len(normalized.Messages) != 3 { + t.Fatalf("message count = %d", len(normalized.Messages)) } - if !strings.Contains(normalized.ChatRequest.Messages[1].Text, "json action") || !strings.Contains(normalized.ChatRequest.Messages[1].Text, "\"tool\": \"ping\"") { - t.Fatalf("assistant tool call was not rewritten into action format: %q", normalized.ChatRequest.Messages[1].Text) + if normalized.Messages[0].Role != "user" || normalized.Messages[0].Text != "Hello" { + t.Fatalf("first message = %+v", normalized.Messages[0]) } - if normalized.ChatRequest.Messages[2].Role != "user" { - t.Fatalf("tool result role = %q", normalized.ChatRequest.Messages[2].Role) + if normalized.Messages[1].Role != "assistant" || normalized.Messages[1].Text != "Hi" { + t.Fatalf("second message = %+v", normalized.Messages[1]) } - if !strings.Contains(normalized.ChatRequest.Messages[2].Text, "pong") { - t.Fatalf("tool result was not converted into a follow-up prompt: %q", normalized.ChatRequest.Messages[2].Text) + if normalized.Messages[2].Role != "user" || normalized.Messages[2].Text != "Follow up" { + t.Fatalf("third message = %+v", normalized.Messages[2]) } } -func TestNormalizeAnthropicRequestKeepsEmulationForToolHistoryWithoutTools(t *testing.T) { - s := &Server{cfg: Config{EmulateToolCalls: true}} - req := anthropicRequest{ +func TestNormalizeOpenAIRequestRejectsMissingUserAndAssistantMessages(t *testing.T) { + req := openAIChatRequest{ Model: "test-model", + Messages: []rawMessage{ + {Role: "system", Content: "Only system"}, + {Role: "tool", Content: "ignored"}, + }, + } + + _, err := normalizeOpenAIRequest(req) + if err == nil { + t.Fatal("expected error for request without user or assistant messages") + } +} + +func TestNormalizeAnthropicRequestExtractsStructuredText(t *testing.T) { + req := anthropicRequest{ + Model: "test-model", + System: []any{map[string]any{"type": "text", "text": "System prompt"}}, Messages: []rawMessage{ { Role: "user", Content: []any{ - map[string]any{"type": "text", "text": "Use ping, then after the tool result reply FINAL_OK."}, + map[string]any{"type": "text", "text": "Hello"}, }, }, { Role: "assistant", Content: []any{ - map[string]any{"type": "tool_use", "id": "call_1", "name": "ping", "input": map[string]any{"value": "x"}}, + map[string]any{"type": "text", "text": "Hi"}, }, }, { - Role: "user", + Role: "metadata", Content: []any{ - map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": []any{map[string]any{"type": "text", "text": "pong"}}}, + map[string]any{"type": "text", "text": "ignored"}, }, }, }, } - normalized, err := s.normalizeAnthropicRequest(req) + normalized, err := normalizeAnthropicRequest(req) if err != nil { t.Fatalf("normalizeAnthropicRequest() error = %v", err) } - if !normalized.Emulated { - t.Fatalf("expected emulation to stay enabled when anthropic tool history exists") + if normalized.Model != "test-model" { + t.Fatalf("model = %q", normalized.Model) } - if len(normalized.ChatRequest.Messages) != 3 { - t.Fatalf("message count = %d", len(normalized.ChatRequest.Messages)) + if normalized.System != "System prompt" { + t.Fatalf("system = %q", normalized.System) } - if normalized.ChatRequest.Messages[1].Role != "assistant" { - t.Fatalf("assistant message role = %q", normalized.ChatRequest.Messages[1].Role) + if len(normalized.Messages) != 2 { + t.Fatalf("message count = %d", len(normalized.Messages)) } - if !strings.Contains(normalized.ChatRequest.Messages[1].Text, "json action") || !strings.Contains(normalized.ChatRequest.Messages[1].Text, "\"tool\": \"ping\"") { - t.Fatalf("assistant tool_use was not rewritten into action format: %q", normalized.ChatRequest.Messages[1].Text) + if normalized.Messages[0].Role != "user" || normalized.Messages[0].Text != "Hello" { + t.Fatalf("first message = %+v", normalized.Messages[0]) } - if normalized.ChatRequest.Messages[2].Role != "user" { - t.Fatalf("tool result role = %q", normalized.ChatRequest.Messages[2].Role) - } - if !strings.Contains(normalized.ChatRequest.Messages[2].Text, "pong") { - t.Fatalf("tool result was not converted into a follow-up prompt: %q", normalized.ChatRequest.Messages[2].Text) + if normalized.Messages[1].Role != "assistant" || normalized.Messages[1].Text != "Hi" { + t.Fatalf("second message = %+v", normalized.Messages[1]) + } +} + +func TestNormalizeAnthropicRequestRejectsEmptyMessages(t *testing.T) { + req := anthropicRequest{ + Model: "test-model", + Messages: []rawMessage{ + {Role: "user", Content: ""}, + {Role: "assistant", Content: nil}, + }, + } + + _, err := normalizeAnthropicRequest(req) + if err == nil { + t.Fatal("expected error for request without usable messages") } }