From 1aa1bc7a26d1bc2ffbd01c8f12ac1750989407d0 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 22 Mar 2026 10:25:47 +0900 Subject: [PATCH 1/7] feat(providers): add OpencodeExecutor for opencode-zen/go multi-format routing --- open-sse/executors/opencode.ts | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 open-sse/executors/opencode.ts diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts new file mode 100644 index 0000000000..51a0472852 --- /dev/null +++ b/open-sse/executors/opencode.ts @@ -0,0 +1,61 @@ +import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { getModelTargetFormat } from "../config/providerModels.ts"; + +export class OpencodeExecutor extends BaseExecutor { + _requestFormat: string | null = null; + + constructor(provider: string) { + super(provider, PROVIDERS[provider] || PROVIDERS.openai); + } + + async execute(input: ExecuteInput) { + this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai"; + try { + return await super.execute(input); + } finally { + this._requestFormat = null; + } + } + + buildUrl( + model: string, + stream: boolean, + urlIndex = 0, + credentials: ProviderCredentials | null = null + ) { + void urlIndex; + void credentials; + + const base = this.config.baseUrl; + switch (this._requestFormat) { + case "claude": + return `${base}/messages`; + case "openai-responses": + return `${base}/responses`; + case "gemini": + return `${base}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`; + default: + return `${base}/chat/completions`; + } + } + + buildHeaders(credentials: ProviderCredentials | null, stream = true) { + const headers: Record = { "Content-Type": "application/json" }; + const key = credentials?.apiKey || credentials?.accessToken; + + if (key) { + headers["Authorization"] = `Bearer ${key}`; + } + + if (this._requestFormat === "claude") { + headers["anthropic-version"] = "2023-06-01"; + } + + if (stream) { + headers["Accept"] = "text/event-stream"; + } + + return headers; + } +} From e08d6616005378f2235059454edebb6b1a636e86 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 22 Mar 2026 10:30:08 +0900 Subject: [PATCH 2/7] feat(providers): register opencode executors and add UI metadata - Register OpencodeExecutor for 'opencode-zen' and 'opencode-go' in executors map - Add OpencodeExecutor export in index.ts - Add UI metadata for both providers in APIKEY_PROVIDERS: - OpenCode Zen: https://opencode.ai/zen - OpenCode Go: https://opencode.ai/zen/go - Both use 'opencode' icon with #6366f1 color --- open-sse/executors/index.ts | 4 ++++ src/shared/constants/providers.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 0544119dca..a9e37f6048 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -8,6 +8,7 @@ import { CursorExecutor } from "./cursor.ts"; import { DefaultExecutor } from "./default.ts"; import { PollinationsExecutor } from "./pollinations.ts"; import { CloudflareAIExecutor } from "./cloudflare-ai.ts"; +import { OpencodeExecutor } from "./opencode.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -22,6 +23,8 @@ const executors = { pol: new PollinationsExecutor(), // Alias "cloudflare-ai": new CloudflareAIExecutor(), cf: new CloudflareAIExecutor(), // Alias + "opencode-zen": new OpencodeExecutor("opencode-zen"), + "opencode-go": new OpencodeExecutor("opencode-go"), }; const defaultCache = new Map(); @@ -47,3 +50,4 @@ export { CursorExecutor } from "./cursor.ts"; export { DefaultExecutor } from "./default.ts"; export { PollinationsExecutor } from "./pollinations.ts"; export { CloudflareAIExecutor } from "./cloudflare-ai.ts"; +export { OpencodeExecutor } from "./opencode.ts"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 8124f18936..8203aad08e 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -496,6 +496,22 @@ export const APIKEY_PROVIDERS = { website: "https://tavily.com", authHint: "API key from app.tavily.com (format: tvly-...)", }, + "opencode-zen": { + id: "opencode-zen", + alias: "opencode-zen", + name: "OpenCode Zen", + icon: "opencode", + color: "#6366f1", + website: "https://opencode.ai/zen", + }, + "opencode-go": { + id: "opencode-go", + alias: "opencode-go", + name: "OpenCode Go", + icon: "opencode", + color: "#6366f1", + website: "https://opencode.ai/zen/go", + }, alibaba: { id: "alibaba", alias: "ali", From da1ed1b5b27f867075d4fd136ef9e25fddcf67b3 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 22 Mar 2026 10:31:39 +0900 Subject: [PATCH 3/7] feat(providers): register opencode-zen and opencode-go in provider registry --- open-sse/config/providerRegistry.ts | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 3d19e360ce..7dc2152795 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -495,6 +495,39 @@ export const REGISTRY: Record = { ], }, + "opencode-go": { + id: "opencode-go", + alias: "opencode-go", + format: "openai", + executor: "opencode", + baseUrl: "https://opencode.ai/zen/go/v1", + authType: "apikey", + authHeader: "Authorization", + authPrefix: "Bearer", + models: [ + { id: "glm-5", name: "GLM-5" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, + { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, + ], + }, + + "opencode-zen": { + id: "opencode-zen", + alias: "opencode-zen", + format: "openai", + executor: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + authType: "apikey", + authHeader: "Authorization", + authPrefix: "Bearer", + models: [ + { id: "minimax-m2.5-free", name: "MiniMax M2.5 Free" }, + { id: "big-pickle", name: "Big Pickle" }, + { id: "gpt-5-nano", name: "GPT 5 Nano" }, + ], + }, + openrouter: { id: "openrouter", alias: "openrouter", From 53474021b7f0983cdb060bc4793b736bf519ac35 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 22 Mar 2026 10:35:06 +0900 Subject: [PATCH 4/7] test(providers): add unit tests for OpencodeExecutor --- tests/unit/opencode-executor.test.mjs | 124 ++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/unit/opencode-executor.test.mjs diff --git a/tests/unit/opencode-executor.test.mjs b/tests/unit/opencode-executor.test.mjs new file mode 100644 index 0000000000..45c0924d63 --- /dev/null +++ b/tests/unit/opencode-executor.test.mjs @@ -0,0 +1,124 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts"); +const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts"); + +function setRequestFormat(executor, provider, model, overrideFormat) { + executor._requestFormat = overrideFormat ?? getModelTargetFormat(provider, model); +} + +describe("OpencodeExecutor", () => { + let zenExecutor; + let goExecutor; + + beforeEach(() => { + zenExecutor = new OpencodeExecutor("opencode-zen"); + goExecutor = new OpencodeExecutor("opencode-go"); + }); + + describe("buildUrl", () => { + it("returns chat completions for opencode zen default models", () => { + setRequestFormat(zenExecutor, "opencode-zen", "minimax-m2.5-free"); + assert.equal( + zenExecutor.buildUrl("minimax-m2.5-free", true), + "https://opencode.ai/zen/v1/chat/completions" + ); + + setRequestFormat(zenExecutor, "opencode-zen", "big-pickle"); + assert.equal( + zenExecutor.buildUrl("big-pickle", true), + "https://opencode.ai/zen/v1/chat/completions" + ); + + setRequestFormat(zenExecutor, "opencode-zen", "gpt-5-nano"); + assert.equal( + zenExecutor.buildUrl("gpt-5-nano", true), + "https://opencode.ai/zen/v1/chat/completions" + ); + }); + + it("returns messages endpoint for claude target format models", () => { + setRequestFormat(goExecutor, "opencode-go", "minimax-m2.7"); + assert.equal( + goExecutor.buildUrl("minimax-m2.7", true), + "https://opencode.ai/zen/go/v1/messages" + ); + + setRequestFormat(goExecutor, "opencode-go", "minimax-m2.5"); + assert.equal( + goExecutor.buildUrl("minimax-m2.5", true), + "https://opencode.ai/zen/go/v1/messages" + ); + }); + + it("returns responses endpoint for openai responses target format", () => { + setRequestFormat(zenExecutor, "opencode-zen", "gpt-5-nano", "openai-responses"); + assert.equal( + zenExecutor.buildUrl("gpt-5-nano", true), + "https://opencode.ai/zen/v1/responses" + ); + }); + + it("returns gemini streaming endpoint when stream is true", () => { + setRequestFormat(zenExecutor, "opencode-zen", "gemini-2.5-pro", "gemini"); + assert.equal( + zenExecutor.buildUrl("gemini-2.5-pro", true), + "https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse" + ); + }); + + it("returns gemini non streaming endpoint when stream is false", () => { + setRequestFormat(zenExecutor, "opencode-zen", "gemini-2.5-pro", "gemini"); + assert.equal( + zenExecutor.buildUrl("gemini-2.5-pro", false), + "https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent" + ); + }); + + it("falls back to chat completions for unknown models", () => { + setRequestFormat(zenExecutor, "opencode-zen", "unknown-model"); + assert.equal( + zenExecutor.buildUrl("unknown-model", true), + "https://opencode.ai/zen/v1/chat/completions" + ); + }); + }); + + describe("buildHeaders", () => { + it("builds default headers for standard models", () => { + setRequestFormat(zenExecutor, "opencode-zen", "gpt-5-nano"); + assert.deepEqual(zenExecutor.buildHeaders({ apiKey: "test-key" }), { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + Accept: "text/event-stream", + }); + }); + + it("adds anthropic version for claude target format", () => { + setRequestFormat(goExecutor, "opencode-go", "minimax-m2.7"); + assert.deepEqual(goExecutor.buildHeaders({ apiKey: "claude-key" }), { + Authorization: "Bearer claude-key", + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + Accept: "text/event-stream", + }); + }); + + it("omits accept header when stream is false", () => { + setRequestFormat(zenExecutor, "opencode-zen", "big-pickle"); + assert.deepEqual(zenExecutor.buildHeaders({ apiKey: "test-key" }, false), { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }); + }); + + it("omits authorization when credentials are missing", () => { + setRequestFormat(zenExecutor, "opencode-zen", "minimax-m2.5-free"); + assert.deepEqual(zenExecutor.buildHeaders(null), { + "Content-Type": "application/json", + Accept: "text/event-stream", + }); + }); + }); +}); From 8b7e7c2669d39ced445e01e35fb34fd7372922e0 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 22 Mar 2026 11:10:12 +0900 Subject: [PATCH 5/7] test(providers): improve OpencodeExecutor tests to avoid internal state coupling --- tests/unit/opencode-executor.test.mjs | 191 +++++++++++++++++--------- 1 file changed, 128 insertions(+), 63 deletions(-) diff --git a/tests/unit/opencode-executor.test.mjs b/tests/unit/opencode-executor.test.mjs index 45c0924d63..11710961f7 100644 --- a/tests/unit/opencode-executor.test.mjs +++ b/tests/unit/opencode-executor.test.mjs @@ -1,124 +1,189 @@ -import { beforeEach, describe, it } from "node:test"; +import { afterEach, beforeEach, describe, it } from "node:test"; import assert from "node:assert/strict"; const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts"); -const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts"); +const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts"); -function setRequestFormat(executor, provider, model, overrideFormat) { - executor._requestFormat = overrideFormat ?? getModelTargetFormat(provider, model); +function createMockResponse() { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function createInput(model, stream = true, credentials = { apiKey: "test-key" }) { + return { + model, + stream, + credentials, + body: { + model, + stream, + messages: [{ role: "user", content: "hello" }], + }, + }; +} + +function registerModel(provider, model) { + PROVIDER_MODELS[provider] = [...(PROVIDER_MODELS[provider] || []), model]; } describe("OpencodeExecutor", () => { let zenExecutor; let goExecutor; + let fetchCalls; + let originalFetch; + let originalZenModels; + let originalGoModels; beforeEach(() => { zenExecutor = new OpencodeExecutor("opencode-zen"); goExecutor = new OpencodeExecutor("opencode-go"); + fetchCalls = []; + originalFetch = globalThis.fetch; + originalZenModels = [...(PROVIDER_MODELS["opencode-zen"] || [])]; + originalGoModels = [...(PROVIDER_MODELS["opencode-go"] || [])]; + globalThis.fetch = async (url, options) => { + fetchCalls.push({ url, options }); + return createMockResponse(); + }; }); - describe("buildUrl", () => { - it("returns chat completions for opencode zen default models", () => { - setRequestFormat(zenExecutor, "opencode-zen", "minimax-m2.5-free"); - assert.equal( - zenExecutor.buildUrl("minimax-m2.5-free", true), - "https://opencode.ai/zen/v1/chat/completions" - ); + afterEach(() => { + globalThis.fetch = originalFetch; + PROVIDER_MODELS["opencode-zen"] = originalZenModels; + PROVIDER_MODELS["opencode-go"] = originalGoModels; + }); - setRequestFormat(zenExecutor, "opencode-zen", "big-pickle"); - assert.equal( - zenExecutor.buildUrl("big-pickle", true), - "https://opencode.ai/zen/v1/chat/completions" - ); + describe("execute", () => { + it("routes opencode zen default models to chat completions", async () => { + const minimaxResult = await zenExecutor.execute(createInput("minimax-m2.5-free")); + assert.equal(minimaxResult.url, "https://opencode.ai/zen/v1/chat/completions"); + assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions"); - setRequestFormat(zenExecutor, "opencode-zen", "gpt-5-nano"); - assert.equal( - zenExecutor.buildUrl("gpt-5-nano", true), - "https://opencode.ai/zen/v1/chat/completions" - ); + const pickleResult = await zenExecutor.execute(createInput("big-pickle")); + assert.equal(pickleResult.url, "https://opencode.ai/zen/v1/chat/completions"); + assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/v1/chat/completions"); + + const nanoResult = await zenExecutor.execute(createInput("gpt-5-nano")); + assert.equal(nanoResult.url, "https://opencode.ai/zen/v1/chat/completions"); + assert.equal(fetchCalls[2].url, "https://opencode.ai/zen/v1/chat/completions"); }); - it("returns messages endpoint for claude target format models", () => { - setRequestFormat(goExecutor, "opencode-go", "minimax-m2.7"); - assert.equal( - goExecutor.buildUrl("minimax-m2.7", true), - "https://opencode.ai/zen/go/v1/messages" + it("routes claude target format models to messages endpoint", async () => { + const m27Result = await goExecutor.execute( + createInput("minimax-m2.7", true, { apiKey: "claude-key" }) ); + assert.equal(m27Result.url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(m27Result.headers["anthropic-version"], "2023-06-01"); - setRequestFormat(goExecutor, "opencode-go", "minimax-m2.5"); - assert.equal( - goExecutor.buildUrl("minimax-m2.5", true), - "https://opencode.ai/zen/go/v1/messages" + const m25Result = await goExecutor.execute( + createInput("minimax-m2.5", true, { apiKey: "claude-key" }) ); + assert.equal(m25Result.url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(m25Result.headers["anthropic-version"], "2023-06-01"); }); - it("returns responses endpoint for openai responses target format", () => { - setRequestFormat(zenExecutor, "opencode-zen", "gpt-5-nano", "openai-responses"); - assert.equal( - zenExecutor.buildUrl("gpt-5-nano", true), - "https://opencode.ai/zen/v1/responses" - ); + it("routes openai responses target format models to responses endpoint", async () => { + registerModel("opencode-zen", { + id: "gpt-5-responses", + name: "GPT 5 Responses", + targetFormat: "openai-responses", + }); + + const result = await zenExecutor.execute(createInput("gpt-5-responses")); + + assert.equal(result.url, "https://opencode.ai/zen/v1/responses"); + assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/responses"); }); - it("returns gemini streaming endpoint when stream is true", () => { - setRequestFormat(zenExecutor, "opencode-zen", "gemini-2.5-pro", "gemini"); + it("routes gemini streaming requests to streamGenerateContent", async () => { + registerModel("opencode-zen", { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + targetFormat: "gemini", + }); + + const result = await zenExecutor.execute(createInput("gemini-2.5-pro")); + assert.equal( - zenExecutor.buildUrl("gemini-2.5-pro", true), + result.url, + "https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse" + ); + assert.equal( + fetchCalls[0].url, "https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse" ); }); - it("returns gemini non streaming endpoint when stream is false", () => { - setRequestFormat(zenExecutor, "opencode-zen", "gemini-2.5-pro", "gemini"); + it("routes gemini non streaming requests to generateContent", async () => { + registerModel("opencode-zen", { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + targetFormat: "gemini", + }); + + const result = await zenExecutor.execute(createInput("gemini-2.5-pro", false)); + + assert.equal(result.url, "https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent"); assert.equal( - zenExecutor.buildUrl("gemini-2.5-pro", false), + fetchCalls[0].url, "https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent" ); }); - it("falls back to chat completions for unknown models", () => { - setRequestFormat(zenExecutor, "opencode-zen", "unknown-model"); - assert.equal( - zenExecutor.buildUrl("unknown-model", true), - "https://opencode.ai/zen/v1/chat/completions" - ); - }); - }); + it("falls back to chat completions for unknown models", async () => { + const result = await zenExecutor.execute(createInput("unknown-model")); - describe("buildHeaders", () => { - it("builds default headers for standard models", () => { - setRequestFormat(zenExecutor, "opencode-zen", "gpt-5-nano"); - assert.deepEqual(zenExecutor.buildHeaders({ apiKey: "test-key" }), { + assert.equal(result.url, "https://opencode.ai/zen/v1/chat/completions"); + assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions"); + }); + + it("builds default headers for standard models", async () => { + const result = await zenExecutor.execute(createInput("gpt-5-nano")); + + assert.deepEqual(result.headers, { Authorization: "Bearer test-key", "Content-Type": "application/json", Accept: "text/event-stream", }); + assert.deepEqual(fetchCalls[0].options.headers, result.headers); }); - it("adds anthropic version for claude target format", () => { - setRequestFormat(goExecutor, "opencode-go", "minimax-m2.7"); - assert.deepEqual(goExecutor.buildHeaders({ apiKey: "claude-key" }), { + it("adds anthropic version for claude target format", async () => { + const result = await goExecutor.execute( + createInput("minimax-m2.7", true, { apiKey: "claude-key" }) + ); + + assert.deepEqual(result.headers, { Authorization: "Bearer claude-key", "Content-Type": "application/json", "anthropic-version": "2023-06-01", Accept: "text/event-stream", }); + assert.deepEqual(fetchCalls[0].options.headers, result.headers); }); - it("omits accept header when stream is false", () => { - setRequestFormat(zenExecutor, "opencode-zen", "big-pickle"); - assert.deepEqual(zenExecutor.buildHeaders({ apiKey: "test-key" }, false), { + it("omits accept header when stream is false", async () => { + const result = await zenExecutor.execute(createInput("big-pickle", false)); + + assert.deepEqual(result.headers, { Authorization: "Bearer test-key", "Content-Type": "application/json", }); + assert.deepEqual(fetchCalls[0].options.headers, result.headers); }); - it("omits authorization when credentials are missing", () => { - setRequestFormat(zenExecutor, "opencode-zen", "minimax-m2.5-free"); - assert.deepEqual(zenExecutor.buildHeaders(null), { + it("omits authorization when credentials are missing", async () => { + const result = await zenExecutor.execute(createInput("minimax-m2.5-free", true, null)); + + assert.deepEqual(result.headers, { "Content-Type": "application/json", Accept: "text/event-stream", }); + assert.deepEqual(fetchCalls[0].options.headers, result.headers); }); }); }); From 83e367afab8a12cef2c49fc58264e72df0b825e4 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 22 Mar 2026 15:20:59 +0900 Subject: [PATCH 6/7] ci: add GHCR publish workflow for fork deployments --- .github/workflows/ghcr-publish.yml | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/ghcr-publish.yml diff --git a/.github/workflows/ghcr-publish.yml b/.github/workflows/ghcr-publish.yml new file mode 100644 index 0000000000..dbba8a8e1c --- /dev/null +++ b/.github/workflows/ghcr-publish.yml @@ -0,0 +1,72 @@ +name: Publish to GHCR + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version tag to build (e.g. 2.6.0)" + required: true + type: string + +permissions: + contents: read + packages: write + +jobs: + docker: + name: Build and Push Docker (multi-arch) + runs-on: ubuntu-latest + env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/omniroute + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} + + - name: Set up QEMU (for multi-arch builds) + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from release tag or input + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="${GITHUB_REF_NAME}" + VERSION="${VERSION#v}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Publishing Docker image: $IMAGE_NAME:$VERSION" + + - name: Build and push multi-arch image + uses: docker/build-push-action@v7 + with: + context: . + target: runner-base + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + ${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + no-cache: false + env: + DOCKER_BUILDKIT_INLINE_CACHE: 1 + + - name: Inspect image + run: | + docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}" From 44dc564d85d15fa3c9ceb0997d6102c2aba25a38 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 22 Mar 2026 17:52:23 +0900 Subject: [PATCH 7/7] chore: remove GHCR workflow from upstream PR --- .github/workflows/ghcr-publish.yml | 72 ------------------------------ 1 file changed, 72 deletions(-) delete mode 100644 .github/workflows/ghcr-publish.yml diff --git a/.github/workflows/ghcr-publish.yml b/.github/workflows/ghcr-publish.yml deleted file mode 100644 index dbba8a8e1c..0000000000 --- a/.github/workflows/ghcr-publish.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Publish to GHCR - -on: - release: - types: [published] - workflow_dispatch: - inputs: - version: - description: "Version tag to build (e.g. 2.6.0)" - required: true - type: string - -permissions: - contents: read - packages: write - -jobs: - docker: - name: Build and Push Docker (multi-arch) - runs-on: ubuntu-latest - env: - IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/omniroute - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} - - - name: Set up QEMU (for multi-arch builds) - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract version from release tag or input - id: version - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ inputs.version }}" - else - VERSION="${GITHUB_REF_NAME}" - VERSION="${VERSION#v}" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Publishing Docker image: $IMAGE_NAME:$VERSION" - - - name: Build and push multi-arch image - uses: docker/build-push-action@v7 - with: - context: . - target: runner-base - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} - ${{ env.IMAGE_NAME }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max - no-cache: false - env: - DOCKER_BUILDKIT_INLINE_CACHE: 1 - - - name: Inspect image - run: | - docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"