feat: add multi-arch image publishing

This commit is contained in:
zhoujun0601
2026-08-13 11:28:33 -04:00
parent 4fa0a539fa
commit 618fc19bd7
12 changed files with 217 additions and 20 deletions
+2 -1
View File
@@ -7,13 +7,14 @@ COPY frontend/index.html ./index.html
RUN npm ci && npm run build RUN npm ci && npm run build
FROM golang:1.26.0-bookworm AS backend FROM golang:1.26.0-bookworm AS backend
ARG APP_VERSION=0.2.0
WORKDIR /src/backend WORKDIR /src/backend
COPY backend/go.mod backend/go.sum* ./ COPY backend/go.mod backend/go.sum* ./
RUN go mod download RUN go mod download
COPY backend/cmd ./cmd COPY backend/cmd ./cmd
COPY backend/internal ./internal COPY backend/internal ./internal
COPY --from=frontend /src/backend/internal/web/dist ./internal/web/dist COPY --from=frontend /src/backend/internal/web/dist ./internal/web/dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/codex-helper ./cmd/server RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X codex-helper/internal/app.Version=${APP_VERSION}" -o /out/codex-helper ./cmd/server
FROM node:24.19.0-bookworm-slim AS codex FROM node:24.19.0-bookworm-slim AS codex
ARG CODEX_VERSION=0.147.0 ARG CODEX_VERSION=0.147.0
+1 -1
View File
@@ -43,7 +43,7 @@
| 方法与路径 | 鉴权 | 行为 | | 方法与路径 | 鉴权 | 行为 |
| --- | --- | --- | | --- | --- | --- |
| `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,appServer}`。当前版本字段为 `0.2.0`。 | | `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,appServer}`。`version` 为镜像构建时注入的应用版本,未注入时默认为 `0.2.0`。 |
| `POST /api/v1/setup` | 匿名、仅未初始化 | body `{username,password,timezone}`;用户名至少 3 位、密码至少 10 位,否则 400;时区有效时写入,否则使用默认 UTC。事务创建唯一管理员和通用设置,设置 session,返回 `201 {ok:true}`;已初始化返回 409。 | | `POST /api/v1/setup` | 匿名、仅未初始化 | body `{username,password,timezone}`;用户名至少 3 位、密码至少 10 位,否则 400;时区有效时写入,否则使用默认 UTC。事务创建唯一管理员和通用设置,设置 session,返回 `201 {ok:true}`;已初始化返回 409。 |
| `POST /api/v1/auth/login` | 匿名 | body `{username,password}`;未初始化返回 409,错误凭据返回 401,成功设置 session 并返回 `200 {ok:true}`,限流返回 429。 | | `POST /api/v1/auth/login` | 匿名 | body `{username,password}`;未初始化返回 409,错误凭据返回 401,成功设置 session 并返回 `200 {ok:true}`,限流返回 429。 |
| `任意方法 /api/v1/auth/me` | session;非 `GET`/`HEAD` 还需来源头 | `200 {username}`。前端使用 `GET`。 | | `任意方法 /api/v1/auth/me` | session;非 `GET`/`HEAD` 还需来源头 | `200 {username}`。前端使用 `GET`。 |
+1 -1
View File
@@ -32,7 +32,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
} }
} }
a.mu.RUnlock() a.mu.RUnlock()
jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.2.0", "appServer": connected}) jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": Version, "appServer": connected})
return return
} }
if p == "setup" && r.Method == "POST" { if p == "setup" && r.Method == "POST" {
+3
View File
@@ -25,6 +25,9 @@ import (
webassets "codex-helper/internal/web" webassets "codex-helper/internal/web"
) )
// Version is overridden at build time for release images.
var Version = "0.2.0"
type App struct { type App struct {
dataDir string dataDir string
store *store.Store store *store.Store
+22
View File
@@ -23,6 +23,28 @@ func TestSystemStatusRejectsNonGETMethods(t *testing.T) {
} }
} }
func TestSystemStatusReturnsBuildVersion(t *testing.T) {
a := newReminderTestApp(t)
originalVersion := Version
Version = "1.2.3-test"
t.Cleanup(func() { Version = originalVersion })
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
a.api(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var body struct {
Version string `json:"version"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Version != "1.2.3-test" {
t.Fatalf("version = %q; want %q", body.Version, "1.2.3-test")
}
}
func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
a := newReminderTestApp(t) a := newReminderTestApp(t)
a.runtimes[1] = &accountRuntime{} a.runtimes[1] = &accountRuntime{}
+1 -1
View File
@@ -4,7 +4,7 @@
## 状态与路由 ## 状态与路由
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。这些分支只负责交互,服务端 session 才是安全边界。 应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页和登录后侧栏的品牌区域。这些分支只负责交互,服务端 session 才是安全边界。
主题和当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并每 30 秒刷新内存数据;切换账号必须清空旧 Dashboard,避免短暂展示另一账号信息。手动刷新调用账号级 sync 后重新读取 Dashboard。 主题和当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并每 30 秒刷新内存数据;切换账号必须清空旧 Dashboard,避免短暂展示另一账号信息。手动刷新调用账号级 sync 后重新读取 Dashboard。
+13 -1
View File
@@ -4,10 +4,22 @@
## 镜像结构 ## 镜像结构
`Dockerfile` 有四个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;Node 阶段安装固定 `@openai/codex`;最终 Debian bookworm 镜像只包含 CA、时区、后端、Node runtime 和 Codex 包。 `Dockerfile` 有四个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;Node 阶段安装固定 `@openai/codex`;最终 Debian bookworm 镜像只包含 CA、时区、后端、Node runtime 和 Codex 包。`APP_VERSION` 构建参数通过 Go linker 注入状态 API,未指定时默认为 `0.2.0`,前端在品牌区域显示该版本。
前端产物复制到 `backend/internal/web/dist` 后嵌入二进制。运行层使用 UID `10001` 的 system 用户 `helper`,默认 `DATA_DIR=/data`、`LISTEN_ADDR=:8080`,并暴露 `/data` volume 和 8080。健康检查调用二进制自身的 `healthcheck` 子命令。 前端产物复制到 `backend/internal/web/dist` 后嵌入二进制。运行层使用 UID `10001` 的 system 用户 `helper`,默认 `DATA_DIR=/data`、`LISTEN_ADDR=:8080`,并暴露 `/data` volume 和 8080。健康检查调用二进制自身的 `healthcheck` 子命令。
## 发布镜像
仓库根目录的 `push-image.sh` 构建 `linux/amd64`、`linux/arm64` 镜像并推送至 Docker Hub。版本号必须形如 `0.3.0` 或 `0.3.0-beta.1`,同时作为应用版本和镜像标签;脚本还会更新 `latest`:
```bash
docker login -u koalalove
bash push-image.sh 0.3.0
# 完整重新构建:bash push-image.sh 0.3.0 --no-cache
```
脚本会创建并选用 `codex-helper-builder` buildx builder,并尝试通过 `tonistiigi/binfmt` 注册跨架构模拟器。发布前应先完成本地镜像验证;脚本执行成功即会推送远端标签。
## Compose 与持久化 ## Compose 与持久化
`docker-compose.yml` 本地构建 `codex-helper:latest`,将宿主机 8180 映射到容器 8080,并把命名卷 `codex-helper-data` 挂载到 `/data`。全部数据库、密钥、Codex 配置和多账号凭据都依赖这个卷。 `docker-compose.yml` 本地构建 `codex-helper:latest`,将宿主机 8180 映射到容器 8080,并把命名卷 `codex-helper-data` 挂载到 `/data`。全部数据库、密钥、Codex 配置和多账号凭据都依赖这个卷。
+12 -1
View File
@@ -1 +1,12 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script> <!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codex Helper</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26 -14
View File
@@ -90,6 +90,7 @@ function App() {
if (!s.initialized) if (!s.initialized)
return ( return (
<Setup <Setup
version={s.version}
onDone={() => { onDone={() => {
setS({ ...s, initialized: true }); setS({ ...s, initialized: true });
setAuth(true); setAuth(true);
@@ -100,9 +101,15 @@ function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{auth ? ( {auth ? (
<Route path="*" element={<Shell logout={() => setAuth(false)} />} /> <Route
path="*"
element={<Shell version={s.version} logout={() => setAuth(false)} />}
/>
) : ( ) : (
<Route path="*" element={<Login done={() => setAuth(true)} />} /> <Route
path="*"
element={<Login version={s.version} done={() => setAuth(true)} />}
/>
)} )}
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -116,7 +123,15 @@ const Splash = () => (
<div className="spinner" /> <div className="spinner" />
</main> </main>
); );
function Setup({ onDone }: { onDone: () => void }) { function Brand({ version }: { version?: string }) {
return (
<div className="logo">
<Zap /> Codex Helper
{version && <span className="version-badge">v{version}</span>}
</div>
);
}
function Setup({ version, onDone }: { version: string; onDone: () => void }) {
const [form, set] = useState({ const [form, set] = useState({
username: "admin", username: "admin",
password: "", password: "",
@@ -135,9 +150,7 @@ function Setup({ onDone }: { onDone: () => void }) {
return ( return (
<main className="setup"> <main className="setup">
<section className="hero"> <section className="hero">
<div className="logo"> <Brand version={version} />
<Zap /> Codex Helper
</div>
<h1> <h1>
连接你的 Codex 连接你的 Codex
<br /> <br />
@@ -146,6 +159,9 @@ function Setup({ onDone }: { onDone: () => void }) {
<p>一个容器内完成用量洞察、重置提醒与账户管理。</p> <p>一个容器内完成用量洞察、重置提醒与账户管理。</p>
</section> </section>
<form className="panel form" onSubmit={submit}> <form className="panel form" onSubmit={submit}>
<div className="setup-mobile-brand">
<Brand version={version} />
</div>
<small>首次初始化 · 1 / 1</small> <small>首次初始化 · 1 / 1</small>
<h2>创建管理员</h2> <h2>创建管理员</h2>
<label> <label>
@@ -181,7 +197,7 @@ function Setup({ onDone }: { onDone: () => void }) {
</main> </main>
); );
} }
function Login({ done }: { done: () => void }) { function Login({ version, done }: { version: string; done: () => void }) {
const [u, setU] = useState("admin"), const [u, setU] = useState("admin"),
[p, setP] = useState(""), [p, setP] = useState(""),
[e, setE] = useState(""); [e, setE] = useState("");
@@ -199,9 +215,7 @@ function Login({ done }: { done: () => void }) {
} }
}} }}
> >
<div className="logo"> <Brand version={version} />
<Zap /> Codex Helper
</div>
<h2>欢迎回来</h2> <h2>欢迎回来</h2>
<label> <label>
用户名 用户名
@@ -221,7 +235,7 @@ function Login({ done }: { done: () => void }) {
</main> </main>
); );
} }
function Shell({ logout }: { logout: () => void }) { function Shell({ version, logout }: { version: string; logout: () => void }) {
const nav = useNavigate(); const nav = useNavigate();
const [theme, setTheme] = useState(localStorage.theme || "system"); const [theme, setTheme] = useState(localStorage.theme || "system");
useEffect(() => { useEffect(() => {
@@ -236,9 +250,7 @@ function Shell({ logout }: { logout: () => void }) {
return ( return (
<div className="app"> <div className="app">
<aside> <aside>
<div className="logo"> <Brand version={version} />
<Zap /> Codex Helper
</div>
<nav> <nav>
<button onClick={() => nav("/")}> <button onClick={() => nav("/")}>
<Activity /> <Activity />
+17
View File
@@ -76,6 +76,17 @@ select {
color: var(--accent); color: var(--accent);
width: 22px; width: 22px;
} }
.version-badge {
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.2;
white-space: nowrap;
}
.setup { .setup {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
@@ -84,6 +95,9 @@ select {
gap: 8vw; gap: 8vw;
padding: 7vw; padding: 7vw;
} }
.setup-mobile-brand {
display: none;
}
.hero h1 { .hero h1 {
font-size: clamp(42px, 5vw, 76px); font-size: clamp(42px, 5vw, 76px);
line-height: 1.05; line-height: 1.05;
@@ -576,6 +590,9 @@ a {
.hero { .hero {
display: none; display: none;
} }
.setup-mobile-brand {
display: block;
}
.app { .app {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+34
View File
@@ -49,6 +49,40 @@ async function openSettings(page: Page) {
await expect(page.locator(".settings-loading")).toHaveCount(0); await expect(page.locator(".settings-loading")).toHaveCount(0);
} }
test("shows the build version in the authenticated brand", async ({ page }) => {
await openSettings(page);
const badge = page.locator("aside .version-badge");
await expect(badge).toBeVisible();
await expect(badge).toHaveText("vtest");
});
test("shows the build version before setup", async ({ page }) => {
await page.route("**/api/v1/system/status", (route) =>
route.fulfill({ json: { initialized: false, appServer: false, version: "test" } }),
);
await page.goto("/");
const badge = page.locator(
(page.viewportSize()?.width ?? 0) <= 800
? ".setup-mobile-brand .version-badge"
: ".hero .version-badge",
);
await expect(badge).toBeVisible();
await expect(badge).toHaveText("vtest");
});
test("shows the build version on login", async ({ page }) => {
await page.route("**/api/v1/system/status", (route) =>
route.fulfill({ json: { initialized: true, appServer: false, version: "test" } }),
);
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({ status: 401, json: { error: "unauthorized" } }),
);
await page.goto("/");
const badge = page.locator(".login .version-badge");
await expect(badge).toBeVisible();
await expect(badge).toHaveText("vtest");
});
async function layout(page: Page) { async function layout(page: Page) {
return page.evaluate(() => { return page.evaluate(() => {
const box = (selector: string) => { const box = (selector: string) => {
Executable
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# Codex Helper 单容器多架构镜像构建并推送到 Docker Hub。
# 用法: bash push-image.sh <version> [--no-cache]
set -e
cd "$(dirname "$0")"
OWNER="koalalove"
IMAGE="codex-helper"
BUILDER="codex-helper-builder"
VERSION="${1:-}"
NO_CACHE=0
if [ -z "$VERSION" ] || [ "${VERSION:0:2}" = "--" ]; then
echo "✗ 缺少版本号。用法: bash push-image.sh <version> [--no-cache]" >&2
echo " 例: bash push-image.sh 0.3.0 --no-cache" >&2
exit 1
fi
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$'; then
echo "✗ 版本号格式非法: '$VERSION'(应形如 0.3.0 或 0.3.0-beta.1)" >&2
exit 1
fi
shift
for arg in "$@"; do
case "$arg" in
--no-cache) NO_CACHE=1 ;;
*)
echo "✗ 未知参数: $arg" >&2
exit 1
;;
esac
done
echo "=================================================="
echo " Codex Helper 单容器多架构镜像构建"
echo " 版本号: v${VERSION}(镜像 tag + 界面显示)"
echo " 目标仓库: ${OWNER}/${IMAGE}"
echo " 目标平台: linux/amd64, linux/arm64"
if [ "$NO_CACHE" = "1" ]; then
echo " 缓存策略: --no-cache(完整重新构建)"
else
echo " 缓存策略: 使用缓存(加 --no-cache 参数可禁用)"
fi
echo "=================================================="
echo ""
echo "请确保已执行: docker login -u ${OWNER}"
echo ""
echo "[准备] 注册 QEMU/binfmt 多架构模拟..."
docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1 \
|| echo "[警告] binfmt 注册失败(若宿主已内置模拟可忽略),继续尝试构建。"
if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
echo "[准备] 创建多架构 buildx builder: ${BUILDER}..."
docker buildx create --name "$BUILDER" --driver docker-container --bootstrap
fi
docker buildx use "$BUILDER"
BUILD_ARGS=(
--platform linux/amd64,linux/arm64
--file Dockerfile
--build-arg "APP_VERSION=${VERSION}"
--tag "${OWNER}/${IMAGE}:${VERSION}"
--tag "${OWNER}/${IMAGE}:latest"
--push
)
[ "$NO_CACHE" = "1" ] && BUILD_ARGS+=(--no-cache)
echo "[1/1] 构建并推送 Codex Helper 镜像 v${VERSION}..."
docker buildx build "${BUILD_ARGS[@]}" .
echo ""
echo "=================================================="
echo " ✓ 构建推送完成!"
echo " 镜像: ${OWNER}/${IMAGE}:${VERSION}"
echo " ${OWNER}/${IMAGE}:latest"
echo ""
echo " 快速启动:"
echo " docker run -d \\"
echo " -p 8180:8080 \\"
echo " -v codex-helper-data:/data \\"
echo " --name codex-helper \\"
echo " ${OWNER}/${IMAGE}:${VERSION}"
echo "=================================================="