diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 874d40b5..23b7deb9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,68 +1,60 @@ -name: Docker Image CI +name: Docker Publish (Multi-Arch) on: push: - branches: [ "master", "main" ] - # 当发布新版本时触发 - tags: [ 'v*.*.*' ] + branches: ["master", "main"] + tags: ["v*.*.*"] pull_request: - branches: [ "master", "main" ] + branches: ["master", "main"] + workflow_dispatch: env: - # GitHub Container Registry 的地址 - REGISTRY: ghcr.io - # 镜像名称,默认为 GitHub 用户名/仓库名 - IMAGE_NAME: ${{ github.repository }} + IMAGE_NAME: lifj25/codex-console jobs: build-and-push-image: runs-on: ubuntu-latest permissions: contents: read - packages: write - # 如果需要签名生成的镜像,可以使用 id-token: write - steps: - name: Checkout repository uses: actions/checkout@v4 - # 设置 Docker Buildx 用于构建多平台镜像 (可选) + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # 登录到 Docker 镜像仓库 - # 如果只是在 PR 中测试构建,则跳过登录 - - name: Log in to the Container registry + - name: Log in to Docker Hub if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - # 提取 Docker 镜像的元数据(标签、注释等) - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: docker.io/${{ env.IMAGE_NAME }} tags: | - type=schedule type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha - type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - # 构建并推送 Docker 镜像 - - name: Build and push Docker image + - name: Build and push Docker image (linux/amd64,linux/arm64) uses: docker/build-push-action@v5 with: context: . + platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} + provenance: false tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/.github/workflows/upstream-release-sync.yml b/.github/workflows/upstream-release-sync.yml new file mode 100644 index 00000000..957d03ed --- /dev/null +++ b/.github/workflows/upstream-release-sync.yml @@ -0,0 +1,284 @@ +name: Upstream Release Sync Docker + +on: + schedule: + - cron: "7 * * * *" + workflow_dispatch: + inputs: + upstream_tag: + description: "Optional upstream tag, for example v1.1.2" + required: false + type: string + force_republish: + description: "Force rebuild and push even if the version tag already exists" + required: false + default: false + type: boolean + publish_latest: + description: "Also publish the latest tag for this run" + required: false + default: true + type: boolean + +env: + UPSTREAM_REPO: https://github.com/dou-jiang/codex-console.git + IMAGE_NAME: lifj25/codex-console + PLATFORMS: linux/amd64,linux/arm64 + +jobs: + sync-and-publish: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: upstream-release-sync + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git identity + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Resolve upstream tag + id: resolve + shell: bash + run: | + set -Eeuo pipefail + + git remote add upstream "${UPSTREAM_REPO}" || git remote set-url upstream "${UPSTREAM_REPO}" + git fetch --force --tags upstream + + if [[ -n "${{ github.event.inputs.upstream_tag }}" ]]; then + UPSTREAM_TAG="${{ github.event.inputs.upstream_tag }}" + else + UPSTREAM_TAG="$(git ls-remote --tags --refs --sort='version:refname' upstream 'v*' | tail -n 1 | awk '{print $2}' | sed 's#refs/tags/##')" + fi + + if [[ -z "${UPSTREAM_TAG}" ]]; then + echo "未找到上游 release tag" >&2 + exit 1 + fi + + VERSION="${UPSTREAM_TAG#v}" + + echo "upstream_tag=${UPSTREAM_TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Check image existence + id: image + shell: bash + run: | + set -Eeuo pipefail + + VERSION="${{ steps.resolve.outputs.version }}" + FORCE_REPUBLISH="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish || 'false' }}" + if docker manifest inspect "${IMAGE_NAME}:${VERSION}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + if [[ "$FORCE_REPUBLISH" == 'true' ]]; then + echo "镜像 ${IMAGE_NAME}:${VERSION} 已存在,但本次启用了强制重发,将继续构建。" >> "$GITHUB_STEP_SUMMARY" + else + echo "镜像 ${IMAGE_NAME}:${VERSION} 已存在,跳过构建。" >> "$GITHUB_STEP_SUMMARY" + fi + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Stop when image already exists + if: steps.image.outputs.exists == 'true' && !(github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish == 'true') + shell: bash + run: exit 0 + + - name: Prepare source from upstream tag + id: prepare + if: steps.image.outputs.exists != 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish == 'true') + shell: bash + run: | + set -Eeuo pipefail + + LIGHTWEIGHT_DOCKERFILE="$RUNNER_TEMP/Dockerfile.lightweight" + cp "$GITHUB_WORKSPACE/Dockerfile" "$LIGHTWEIGHT_DOCKERFILE" + + git checkout --detach "refs/tags/${{ steps.resolve.outputs.upstream_tag }}" + cp "$LIGHTWEIGHT_DOCKERFILE" "$GITHUB_WORKSPACE/Dockerfile" + + python - <<'PY' + from pathlib import Path + import re + + file_path = Path("src/web/routes/registration.py") + text = file_path.read_text() + + if "request.registration_type" in text: + signature_pattern = re.compile( + r"(async def run_outlook_batch_registration\([\s\S]*?new_api_service_ids: List\[int\] = None,\n)(\):)", + re.M, + ) + text, sig_count = signature_pattern.subn( + r"\1 registration_type: str = RoleTag.CHILD.value,\n\2", + text, + count=1, + ) + if sig_count == 0 and "registration_type: str = RoleTag.CHILD.value" not in text: + raise SystemExit("Outlook batch signature patch target not found") + + call_pattern = re.compile( + r"(auto_upload_tm=auto_upload_tm,\n\s*tm_service_ids=tm_service_ids,\n\s*auto_upload_new_api=auto_upload_new_api,\n\s*new_api_service_ids=new_api_service_ids,\n)(\s*\))", + re.M, + ) + text, call_count = call_pattern.subn( + r"\1 registration_type=registration_type,\n\2", + text, + count=1, + ) + if call_count == 0 and "registration_type=registration_type" not in text: + raise SystemExit("Outlook batch run_batch_registration patch target not found") + + file_path.write_text(text) + PY + + python -m py_compile src/web/routes/registration.py + + echo "build_revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + if: steps.image.outputs.exists != 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish == 'true') + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + if: steps.image.outputs.exists != 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish == 'true') + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: steps.image.outputs.exists != 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish == 'true') + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate image tags + id: tags + if: steps.image.outputs.exists != 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish == 'true') + shell: bash + run: | + set -Eeuo pipefail + + TAGS="${IMAGE_NAME}:${{ steps.resolve.outputs.version }}" + PUBLISH_LATEST="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.publish_latest || 'true' }}" + if [[ "$PUBLISH_LATEST" == 'true' ]]; then + TAGS="${TAGS}\n${IMAGE_NAME}:latest" + fi + + { + echo "tags<> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + if: steps.image.outputs.exists != 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish == 'true') + uses: docker/build-push-action@v5 + with: + context: . + platforms: ${{ env.PLATFORMS }} + push: true + provenance: false + tags: ${{ steps.tags.outputs.tags }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.version=${{ steps.resolve.outputs.version }} + org.opencontainers.image.revision=${{ steps.prepare.outputs.build_revision }} + + - name: Summarize result + if: always() + shell: bash + run: | + { + echo "## Upstream release sync" + echo "- Upstream tag: ${{ steps.resolve.outputs.upstream_tag }}" + echo "- Image: ${IMAGE_NAME}:${{ steps.resolve.outputs.version }}" + echo "- Platforms: ${PLATFORMS}" + if [[ "${{ steps.image.outputs.exists }}" == 'true' && "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish || 'false' }}" != 'true' ]]; then + echo "- Result: skipped (already published)" + elif [[ "${{ job.status }}" == 'success' ]]; then + echo "- Result: published" + echo "- Build revision: ${{ steps.prepare.outputs.build_revision }}" + else + echo "- Result: failed" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Send email notification + if: always() && (steps.image.outputs.exists != 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.force_republish == 'true')) + env: + SMTP_HOST: smtp.exmail.qq.com + SMTP_PORT: '465' + SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} + SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} + EMAIL_FROM: ${{ secrets.SMTP_FROM }} + EMAIL_TO: lifujie25@qq.com + UPSTREAM_TAG: ${{ steps.resolve.outputs.upstream_tag }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} + VERSION: ${{ steps.resolve.outputs.version }} + BUILD_REVISION: ${{ steps.prepare.outputs.build_revision }} + JOB_STATUS: ${{ job.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + shell: bash + run: | + if [[ -z "${SMTP_USERNAME}" || -z "${SMTP_PASSWORD}" ]]; then + echo "SMTP 未配置,跳过邮件通知。" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + python - <<'PY' + import os + import smtplib + from email.message import EmailMessage + + status = os.environ.get("JOB_STATUS", "unknown") + upstream_tag = os.environ.get("UPSTREAM_TAG", "unknown") + image_name = os.environ.get("IMAGE_NAME", "unknown") + version = os.environ.get("VERSION", "unknown") + build_revision = os.environ.get("BUILD_REVISION", "") + run_url = os.environ.get("RUN_URL", "") + email_from = os.environ.get("EMAIL_FROM") or os.environ.get("SMTP_USERNAME") + + if status == "success": + subject = f"[codex-console] Docker 发布成功 {upstream_tag}" + body = "\n".join([ + "自动同步并发布已完成。", + f"上游版本: {upstream_tag}", + f"镜像: {image_name}:{version}", + f"latest: {image_name}:latest (if enabled for this run)", + f"构建提交: {build_revision}", + f"运行详情: {run_url}", + ]) + else: + subject = f"[codex-console] Docker 发布失败 {upstream_tag}" + body = "\n".join([ + "自动同步流程执行失败,请查看 GitHub Actions 日志。", + f"上游版本: {upstream_tag}", + f"目标镜像: {image_name}:{version}", + f"运行详情: {run_url}", + ]) + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = email_from + msg["To"] = os.environ["EMAIL_TO"] + msg.set_content(body) + + host = os.environ["SMTP_HOST"] + port = int(os.environ.get("SMTP_PORT", "465")) + username = os.environ["SMTP_USERNAME"] + password = os.environ["SMTP_PASSWORD"] + + with smtplib.SMTP_SSL(host, port) as server: + server.login(username, password) + server.send_message(msg) + PY diff --git a/Dockerfile b/Dockerfile index db633977..6561b69e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app # 设置环境变量 ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - # WebUI 默认配置 + TZ=Asia/Shanghai \ WEBUI_HOST=0.0.0.0 \ WEBUI_PORT=1455 \ LOG_LEVEL=info \ diff --git a/README.md b/README.md index e99b6d02..ee93047d 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ docker run -d \ -e WEBUI_ACCESS_PASSWORD=your_secure_password \ -v $(pwd)/data:/app/data \ --name codex-console \ - ghcr.io//codex-console:latest + lifj25/codex-console:latest ``` 说明: @@ -156,6 +156,22 @@ docker run -d \ `-v $(pwd)/data:/app/data` 很重要,这会把数据库和账号数据持久化到宿主机。否则容器一重启,数据也可能跟着表演消失术。 +### Docker 镜像自动发布(amd64 + arm64) + +仓库内置了 GitHub Actions 工作流,会在 `main/master` 推送时自动构建并发布多架构镜像到 Docker Hub: + +- `linux/amd64` +- `linux/arm64` + +工作流文件: + +`/.github/workflows/docker-publish.yml` + +需要在 GitHub 仓库 Secrets 中配置: + +- `DOCKERHUB_USERNAME` +- `DOCKERHUB_TOKEN` + ## 使用远程 PostgreSQL ```bash diff --git a/src/web/app.py b/src/web/app.py index 09fb509e..c0c16a13 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -108,8 +108,9 @@ def _redirect_to_login(request: Request) -> RedirectResponse: async def login_page(request: Request, next: Optional[str] = "/"): """登录页面""" return templates.TemplateResponse( - "login.html", - {"request": request, "error": "", "next": next or "/"} + request=request, + name="login.html", + context={"request": request, "error": "", "next": next or "/"} ) @app.post("/login") @@ -118,8 +119,9 @@ async def login_submit(request: Request, password: str = Form(...), next: Option expected = get_settings().webui_access_password.get_secret_value() if not secrets.compare_digest(password, expected): return templates.TemplateResponse( - "login.html", - {"request": request, "error": "密码错误", "next": next or "/"}, + request=request, + name="login.html", + context={"request": request, "error": "密码错误", "next": next or "/"}, status_code=401 ) @@ -139,33 +141,33 @@ async def index(request: Request): """首页 - 注册页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("index.html", {"request": request}) + return templates.TemplateResponse(request=request, name="index.html", context={"request": request}) @app.get("/accounts", response_class=HTMLResponse) async def accounts_page(request: Request): """账号管理页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("accounts.html", {"request": request}) + return templates.TemplateResponse(request=request, name="accounts.html", context={"request": request}) @app.get("/email-services", response_class=HTMLResponse) async def email_services_page(request: Request): """邮箱服务管理页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("email_services.html", {"request": request}) + return templates.TemplateResponse(request=request, name="email_services.html", context={"request": request}) @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): """设置页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("settings.html", {"request": request}) + return templates.TemplateResponse(request=request, name="settings.html", context={"request": request}) @app.get("/payment", response_class=HTMLResponse) async def payment_page(request: Request): """支付页面""" - return templates.TemplateResponse("payment.html", {"request": request}) + return templates.TemplateResponse(request=request, name="payment.html", context={"request": request}) @app.on_event("startup") async def startup_event():