feat: add multi-arch image publishing
This commit is contained in:
+2
-1
@@ -7,13 +7,14 @@ COPY frontend/index.html ./index.html
|
||||
RUN npm ci && npm run build
|
||||
|
||||
FROM golang:1.26.0-bookworm AS backend
|
||||
ARG APP_VERSION=0.2.0
|
||||
WORKDIR /src/backend
|
||||
COPY backend/go.mod backend/go.sum* ./
|
||||
RUN go mod download
|
||||
COPY backend/cmd ./cmd
|
||||
COPY backend/internal ./internal
|
||||
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
|
||||
ARG CODEX_VERSION=0.147.0
|
||||
|
||||
+1
-1
@@ -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/auth/login` | 匿名 | body `{username,password}`;未初始化返回 409,错误凭据返回 401,成功设置 session 并返回 `200 {ok:true}`,限流返回 429。 |
|
||||
| `任意方法 /api/v1/auth/me` | session;非 `GET`/`HEAD` 还需来源头 | `200 {username}`。前端使用 `GET`。 |
|
||||
|
||||
@@ -32,7 +32,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
if p == "setup" && r.Method == "POST" {
|
||||
|
||||
@@ -25,6 +25,9 @@ import (
|
||||
webassets "codex-helper/internal/web"
|
||||
)
|
||||
|
||||
// Version is overridden at build time for release images.
|
||||
var Version = "0.2.0"
|
||||
|
||||
type App struct {
|
||||
dataDir string
|
||||
store *store.Store
|
||||
|
||||
@@ -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) {
|
||||
a := newReminderTestApp(t)
|
||||
a.runtimes[1] = &accountRuntime{}
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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` 子命令。
|
||||
|
||||
## 发布镜像
|
||||
|
||||
仓库根目录的 `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 与持久化
|
||||
|
||||
`docker-compose.yml` 本地构建 `codex-helper:latest`,将宿主机 8180 映射到容器 8080,并把命名卷 `codex-helper-data` 挂载到 `/data`。全部数据库、密钥、Codex 配置和多账号凭据都依赖这个卷。
|
||||
|
||||
+12
-1
@@ -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
@@ -90,6 +90,7 @@ function App() {
|
||||
if (!s.initialized)
|
||||
return (
|
||||
<Setup
|
||||
version={s.version}
|
||||
onDone={() => {
|
||||
setS({ ...s, initialized: true });
|
||||
setAuth(true);
|
||||
@@ -100,9 +101,15 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{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>
|
||||
</BrowserRouter>
|
||||
@@ -116,7 +123,15 @@ const Splash = () => (
|
||||
<div className="spinner" />
|
||||
</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({
|
||||
username: "admin",
|
||||
password: "",
|
||||
@@ -135,9 +150,7 @@ function Setup({ onDone }: { onDone: () => void }) {
|
||||
return (
|
||||
<main className="setup">
|
||||
<section className="hero">
|
||||
<div className="logo">
|
||||
<Zap /> Codex Helper
|
||||
</div>
|
||||
<Brand version={version} />
|
||||
<h1>
|
||||
连接你的 Codex
|
||||
<br />
|
||||
@@ -146,6 +159,9 @@ function Setup({ onDone }: { onDone: () => void }) {
|
||||
<p>一个容器内完成用量洞察、重置提醒与账户管理。</p>
|
||||
</section>
|
||||
<form className="panel form" onSubmit={submit}>
|
||||
<div className="setup-mobile-brand">
|
||||
<Brand version={version} />
|
||||
</div>
|
||||
<small>首次初始化 · 1 / 1</small>
|
||||
<h2>创建管理员</h2>
|
||||
<label>
|
||||
@@ -181,7 +197,7 @@ function Setup({ onDone }: { onDone: () => void }) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
function Login({ done }: { done: () => void }) {
|
||||
function Login({ version, done }: { version: string; done: () => void }) {
|
||||
const [u, setU] = useState("admin"),
|
||||
[p, setP] = useState(""),
|
||||
[e, setE] = useState("");
|
||||
@@ -199,9 +215,7 @@ function Login({ done }: { done: () => void }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="logo">
|
||||
<Zap /> Codex Helper
|
||||
</div>
|
||||
<Brand version={version} />
|
||||
<h2>欢迎回来</h2>
|
||||
<label>
|
||||
用户名
|
||||
@@ -221,7 +235,7 @@ function Login({ done }: { done: () => void }) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
function Shell({ logout }: { logout: () => void }) {
|
||||
function Shell({ version, logout }: { version: string; logout: () => void }) {
|
||||
const nav = useNavigate();
|
||||
const [theme, setTheme] = useState(localStorage.theme || "system");
|
||||
useEffect(() => {
|
||||
@@ -236,9 +250,7 @@ function Shell({ logout }: { logout: () => void }) {
|
||||
return (
|
||||
<div className="app">
|
||||
<aside>
|
||||
<div className="logo">
|
||||
<Zap /> Codex Helper
|
||||
</div>
|
||||
<Brand version={version} />
|
||||
<nav>
|
||||
<button onClick={() => nav("/")}>
|
||||
<Activity />
|
||||
|
||||
@@ -76,6 +76,17 @@ select {
|
||||
color: var(--accent);
|
||||
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 {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
@@ -84,6 +95,9 @@ select {
|
||||
gap: 8vw;
|
||||
padding: 7vw;
|
||||
}
|
||||
.setup-mobile-brand {
|
||||
display: none;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(42px, 5vw, 76px);
|
||||
line-height: 1.05;
|
||||
@@ -576,6 +590,9 @@ a {
|
||||
.hero {
|
||||
display: none;
|
||||
}
|
||||
.setup-mobile-brand {
|
||||
display: block;
|
||||
}
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,40 @@ async function openSettings(page: Page) {
|
||||
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) {
|
||||
return page.evaluate(() => {
|
||||
const box = (selector: string) => {
|
||||
|
||||
Executable
+85
@@ -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 "=================================================="
|
||||
Reference in New Issue
Block a user