mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
ci(deploy): 改为 GitHub Webhook 触发部署
This commit is contained in:
1 parent
69bfe3ce0e
commit
469630e7da
11 files changed
+267
-78
No files matched your search
@@ -1,48 +1,32 @@
|
|||||||
name: Deploy WeReadBoard
|
name: Verify WeReadBoard
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
- dev
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
verify:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
concurrency:
|
|
||||||
group: deploy-master
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare SSH
|
- name: Checkout
|
||||||
run: |
|
uses: actions/checkout@v4
|
||||||
mkdir -p ~/.ssh
|
- name: Set up Python
|
||||||
printf '%s\n' "${{ secrets.SERVER_SSH_KEY }}" > ~/.ssh/deploy_key
|
uses: actions/setup-python@v5
|
||||||
chmod 600 ~/.ssh/deploy_key
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
- name: Deploy
|
- name: Install dependencies
|
||||||
env:
|
run: python -m pip install -r requirements.txt
|
||||||
SERVER_HOST: ${{ secrets.SERVER_HOST }}
|
- name: Run Python tests
|
||||||
SERVER_PORT: ${{ secrets.SERVER_PORT }}
|
run: python -m unittest discover -s tests -v
|
||||||
SERVER_USER: ${{ secrets.SERVER_USER }}
|
- name: Run frontend tests
|
||||||
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
|
run: node --test tests/goals_frontend.test.cjs tests/weapi_fallback.test.cjs
|
||||||
SERVICE_NAME: ${{ vars.SERVICE_NAME }}
|
- name: Check deployment scripts
|
||||||
DEPLOY_BRANCH: master
|
run: bash tests/deploy_master_only.test.sh && bash -n deploy.sh
|
||||||
run: |
|
|
||||||
if [ -z "$SERVER_PORT" ]; then
|
|
||||||
SERVER_PORT="22"
|
|
||||||
fi
|
|
||||||
if [ -z "$DEPLOY_PATH" ]; then
|
|
||||||
DEPLOY_PATH="/opt/weread-board/WeReadBoard"
|
|
||||||
fi
|
|
||||||
if [ -z "$SERVICE_NAME" ]; then
|
|
||||||
SERVICE_NAME="weread.service"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" \
|
|
||||||
"cd '$DEPLOY_PATH' &&
|
|
||||||
git fetch origin &&
|
|
||||||
git checkout '$DEPLOY_BRANCH' &&
|
|
||||||
git pull --ff-only origin '$DEPLOY_BRANCH' &&
|
|
||||||
chmod +x ./deploy.sh &&
|
|
||||||
SKIP_GIT_PULL=1 DEPLOY_BRANCH='$DEPLOY_BRANCH' SERVICE_NAME='$SERVICE_NAME' ./deploy.sh"
|
|
||||||
@@ -64,13 +64,13 @@ PORT=8080 ./start_dashboard.sh # 或指定端口
|
|||||||
|
|
||||||
## 自动部署
|
## 自动部署
|
||||||
|
|
||||||
服务器部署使用根目录的 `deploy.sh`:
|
服务器部署使用根目录的 `deploy.sh`,由 GitHub Webhook 通知服务器触发:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DEPLOY_BRANCH=master ./deploy.sh
|
DEPLOY_BRANCH=master ./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本只允许拉取 `master` 分支,随后自动创建 `.venv`、安装 `requirements.txt`、执行数据库迁移并重启 `weread.service`。GitHub Actions 配置在 `.github/workflows/deploy.yml`,只监听 `master` 分支,详细配置见 [master 自动部署](docs/deploy-master.md)。
|
脚本只允许拉取 `master` 分支,随后自动创建 `.venv`、安装 `requirements.txt`、执行数据库迁移并重启 `weread.service`。GitHub Actions 只做代码验证,不再通过 SSH 登录服务器;详细配置见 [master 自动部署](docs/deploy-master.md)。
|
||||||
|
|
||||||
运行端口、Python 环境、HTTPS 证书和接口故障的诊断方法见 [日常维护与故障排查](docs/troubleshooting.md)。
|
运行端口、Python 环境、HTTPS 证书和接口故障的诊断方法见 [日常维护与故障排查](docs/troubleshooting.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 放入现有 laohang.space HTTPS server 块中。
|
||||||
|
location = /github/webhook {
|
||||||
|
proxy_pass http://127.0.0.1:9001/github/webhook;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=WeReadBoard GitHub Webhook Receiver
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=weread
|
||||||
|
WorkingDirectory=/opt/weread-board/WeReadBoard
|
||||||
|
EnvironmentFile=/etc/weread-webhook.env
|
||||||
|
ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python deploy_webhook.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""接收 GitHub push Webhook,并触发服务器上的固定部署脚本。"""
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PROJECT_DIR = "/opt/weread-board/WeReadBoard"
|
||||||
|
DEFAULT_DEPLOY_SCRIPT = "deploy.sh"
|
||||||
|
DEFAULT_REPOSITORY = "KingHWJ/WeReadBoard"
|
||||||
|
DEFAULT_BRANCH = "master"
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config=None):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_mapping(
|
||||||
|
WEBHOOK_SECRET=os.environ.get("GITHUB_WEBHOOK_SECRET", ""),
|
||||||
|
REPOSITORY=os.environ.get("GITHUB_REPOSITORY", DEFAULT_REPOSITORY),
|
||||||
|
BRANCH=DEFAULT_BRANCH,
|
||||||
|
PROJECT_DIR=os.environ.get("DEPLOY_PROJECT_DIR", DEFAULT_PROJECT_DIR),
|
||||||
|
DEPLOY_SCRIPT=os.environ.get("DEPLOY_SCRIPT", DEFAULT_DEPLOY_SCRIPT),
|
||||||
|
)
|
||||||
|
if config:
|
||||||
|
app.config.update(config)
|
||||||
|
if not app.config["WEBHOOK_SECRET"]:
|
||||||
|
raise RuntimeError("GITHUB_WEBHOOK_SECRET 未配置")
|
||||||
|
if app.config["BRANCH"] != DEFAULT_BRANCH:
|
||||||
|
raise RuntimeError("Webhook 只允许部署 master 分支")
|
||||||
|
|
||||||
|
deployment_lock = threading.Lock()
|
||||||
|
deployment_process = None
|
||||||
|
|
||||||
|
def signature_valid(body, signature):
|
||||||
|
if not signature or not signature.startswith("sha256="):
|
||||||
|
return False
|
||||||
|
expected = "sha256=" + hmac.new(
|
||||||
|
app.config["WEBHOOK_SECRET"].encode("utf-8"), body, sha256
|
||||||
|
).hexdigest()
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
def start_deployment():
|
||||||
|
nonlocal deployment_process
|
||||||
|
with deployment_lock:
|
||||||
|
if deployment_process is not None and deployment_process.poll() is None:
|
||||||
|
return False
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DEPLOY_BRANCH"] = DEFAULT_BRANCH
|
||||||
|
script = app.config["DEPLOY_SCRIPT"]
|
||||||
|
if not os.path.isabs(script):
|
||||||
|
script = os.path.join(app.config["PROJECT_DIR"], script)
|
||||||
|
deployment_process = subprocess.Popen(
|
||||||
|
[script],
|
||||||
|
cwd=app.config["PROJECT_DIR"],
|
||||||
|
env=env,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.post("/github/webhook")
|
||||||
|
def github_webhook():
|
||||||
|
body = request.get_data(cache=True)
|
||||||
|
if not signature_valid(body, request.headers.get("X-Hub-Signature-256", "")):
|
||||||
|
return jsonify(error="签名无效"), 401
|
||||||
|
if request.headers.get("X-GitHub-Event") != "push":
|
||||||
|
return jsonify(error="只接受 push 事件"), 202
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
repository = (payload.get("repository") or {}).get("full_name")
|
||||||
|
if repository != app.config["REPOSITORY"]:
|
||||||
|
return jsonify(error="仓库不匹配"), 403
|
||||||
|
if payload.get("ref") != f"refs/heads/{app.config['BRANCH']}" or payload.get("deleted"):
|
||||||
|
return jsonify(error="分支不匹配"), 202
|
||||||
|
|
||||||
|
if not start_deployment():
|
||||||
|
return jsonify(status="already_deploying"), 202
|
||||||
|
return jsonify(status="accepted"), 202
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
application = create_app()
|
||||||
|
application.run(
|
||||||
|
host=os.environ.get("WEBHOOK_HOST", "127.0.0.1"),
|
||||||
|
port=int(os.environ.get("WEBHOOK_PORT", "9001")),
|
||||||
|
)
|
||||||
+2
-2
@@ -43,11 +43,11 @@
|
|||||||
|
|
||||||
## 部署流程
|
## 部署流程
|
||||||
|
|
||||||
GitHub Actions 的 `.github/workflows/deploy.yml` 只监听 `master` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。手动触发也固定部署 `master`,部署脚本会拒绝其他分支。
|
GitHub Actions 的 `.github/workflows/deploy.yml` 只做验证,不登录服务器。`master` push 后由 GitHub Webhook 向 `https://laohang.space/github/webhook` 发送 JSON,服务器上的 `deploy_webhook.py` 校验 HMAC-SHA256、仓库和 `master` 分支,再在固定目录调用 `deploy.sh`。Webhook 不接受请求体中的命令,部署脚本路径由服务器环境变量固定。
|
||||||
|
|
||||||
`deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,按文件名顺序执行 `migrations/*.sql`,最后重启 `weread.service`。
|
`deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,按文件名顺序执行 `migrations/*.sql`,最后重启 `weread.service`。
|
||||||
|
|
||||||
例:用户将已验收代码合并并推送到 `master`,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署。
|
例:用户将已验收代码合并并推送到 `master`,GitHub 发送签名请求;服务器确认仓库为 `KingHWJ/WeReadBoard` 且 ref 为 `refs/heads/master` 后,在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署,错误签名也不会执行脚本。
|
||||||
|
|
||||||
## 正式看板登录认证
|
## 正式看板登录认证
|
||||||
|
|
||||||
|
|||||||
+28
-13
@@ -1,28 +1,37 @@
|
|||||||
# master 自动部署
|
# master Webhook 自动部署
|
||||||
|
|
||||||
GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `master` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `master`、安装依赖、执行全部数据库迁移并重启服务。手动触发时也固定部署 `master`,不能选择其他分支发布。
|
部署链路是:GitHub `master` push → GitHub Webhook → 服务器签名校验 → 服务器本地执行 `deploy.sh`。GitHub Actions 文件 `.github/workflows/deploy.yml` 现在只负责验证,不再通过 SSH 登录服务器。
|
||||||
|
|
||||||
## GitHub 配置
|
## GitHub Webhook 配置
|
||||||
|
|
||||||
已配置的仓库 Secrets:
|
进入仓库 `Settings -> Webhooks -> Add webhook`,配置 Payload URL `https://laohang.space/github/webhook`、Content type `application/json`、Secret(与服务器 `GITHUB_WEBHOOK_SECRET` 完全一致),事件选择 `Just the push event` 并勾选 `Active`。只允许 `KingHWJ/WeReadBoard` 的 `master` push 触发部署。
|
||||||
|
|
||||||
- `SERVER_HOST`
|
Webhook 服务配置文件 `/etc/weread-webhook.env`:
|
||||||
- `SERVER_PORT`
|
|
||||||
- `SERVER_SSH_KEY`
|
|
||||||
- `SERVER_USER`
|
|
||||||
|
|
||||||
可选再配置两个仓库 Variables:
|
```ini
|
||||||
|
GITHUB_WEBHOOK_SECRET=服务器生成的随机密钥
|
||||||
|
GITHUB_REPOSITORY=KingHWJ/WeReadBoard
|
||||||
|
DEPLOY_PROJECT_DIR=/opt/weread-board/WeReadBoard
|
||||||
|
DEPLOY_SCRIPT=/opt/weread-board/WeReadBoard/deploy.sh
|
||||||
|
WEBHOOK_HOST=127.0.0.1
|
||||||
|
WEBHOOK_PORT=9001
|
||||||
|
```
|
||||||
|
|
||||||
- `DEPLOY_PATH`:服务器上的项目目录,例如 `/opt/WeReadBoard`
|
复制 `deploy/weread-webhook.service` 到 `/etc/systemd/system/`,并执行:
|
||||||
- `SERVICE_NAME`:systemd 服务名,例如 `weread.service`
|
|
||||||
|
|
||||||
未配置 Variables 时,workflow 默认使用 `/opt/weread-board/WeReadBoard` 和 `weread.service`。
|
```bash
|
||||||
|
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
|
||||||
|
sudo chmod 600 /etc/weread-webhook.env
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now weread-webhook.service
|
||||||
|
sudo journalctl -u weread-webhook.service -f
|
||||||
|
```
|
||||||
|
|
||||||
## 服务器要求
|
## 服务器要求
|
||||||
|
|
||||||
服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/master`。运行服务的环境变量仍按现有应用配置,例如 `WEREAD_API_KEY`、`WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME`。
|
服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/master`。运行服务的环境变量仍按现有应用配置,例如 `WEREAD_API_KEY`、`WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME`。
|
||||||
|
|
||||||
如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。SSH 用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。
|
如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。Webhook 运行用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。
|
||||||
|
|
||||||
`deploy.sh` 会执行:
|
`deploy.sh` 会执行:
|
||||||
|
|
||||||
@@ -72,3 +81,9 @@ WantedBy=multi-user.target
|
|||||||
登录认证还需配置 `WEREAD_SESSION_SECRET` 和首次使用的 `WEREAD_AUTH_SETUP_TOKEN`。在服务器分别运行两次 `python3 -c 'import secrets; print(secrets.token_urlsafe(48))'` 生成不同的值。迁移自动创建 `weread_auth` 表;首次访问 `/login` 时输入初始化口令、管理员账号和至少 12 位密码。初始化成功后,从 systemd 配置中删除 `WEREAD_AUTH_SETUP_TOKEN` 并重启服务。数据库中的密码只保存哈希。
|
登录认证还需配置 `WEREAD_SESSION_SECRET` 和首次使用的 `WEREAD_AUTH_SETUP_TOKEN`。在服务器分别运行两次 `python3 -c 'import secrets; print(secrets.token_urlsafe(48))'` 生成不同的值。迁移自动创建 `weread_auth` 表;首次访问 `/login` 时输入初始化口令、管理员账号和至少 12 位密码。初始化成功后,从 systemd 配置中删除 `WEREAD_AUTH_SETUP_TOKEN` 并重启服务。数据库中的密码只保存哈希。
|
||||||
|
|
||||||
`WEREAD_SESSION_COOKIE_SECURE=1` 要求浏览器通过 HTTPS 访问。仅本机 HTTP 验收可临时设为 `0`,不要在公网 HTTP 下传输登录密码。
|
`WEREAD_SESSION_COOKIE_SECURE=1` 要求浏览器通过 HTTPS 访问。仅本机 HTTP 验收可临时设为 `0`,不要在公网 HTTP 下传输登录密码。
|
||||||
|
|
||||||
|
## Nginx HTTPS 转发
|
||||||
|
|
||||||
|
将 `deploy/nginx-github-webhook.conf` 的 `location` 放入 `laohang.space` 的 HTTPS server 块,然后执行 `sudo nginx -t && sudo systemctl reload nginx`。Webhook 服务只监听 `127.0.0.1:9001`,不要对公网开放 9001 端口。
|
||||||
|
|
||||||
|
`.github/workflows/deploy.yml` 只执行 Python、前端和部署脚本检查。Webhook 模式不再需要 `SERVER_HOST`、`SERVER_PORT`、`SERVER_USER`、`SERVER_SSH_KEY` 这些 Actions Secrets。
|
||||||
+5
-17
@@ -109,7 +109,7 @@ python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
|
|||||||
|
|
||||||
应用本身未实现登录尝试限流。公网部署还应在 Nginx 或其他可信入口对 `POST /auth/login` 配置频率限制,不能信任客户端自行传入的转发 IP。
|
应用本身未实现登录尝试限流。公网部署还应在 Nginx 或其他可信入口对 `POST /auth/login` 配置频率限制,不能信任客户端自行传入的转发 IP。
|
||||||
|
|
||||||
## GitHub 自动部署
|
## GitHub Webhook 自动部署
|
||||||
|
|
||||||
服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、按顺序执行 `migrations/*.sql`,并重启 systemd 服务。
|
服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、按顺序执行 `migrations/*.sql`,并重启 systemd 服务。
|
||||||
|
|
||||||
@@ -121,21 +121,9 @@ SERVICE_NAME=weread.service
|
|||||||
VENV_DIR=/opt/weread-board/WeReadBoard/.venv
|
VENV_DIR=/opt/weread-board/WeReadBoard/.venv
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub Actions 文件位于 `.github/workflows/deploy.yml`,只在 `master` 分支 push 后触发,也支持手动触发;手动触发同样固定部署 `master`。仓库需要在 `Settings -> Secrets and variables -> Actions -> Secrets` 配置:
|
GitHub Actions 文件位于 `.github/workflows/deploy.yml`,现在只负责测试和部署脚本检查。生产部署由 GitHub Webhook 触发:仓库 `Settings -> Webhooks` 的 Payload URL 配置为 `https://laohang.space/github/webhook`,Secret 与服务器 `/etc/weread-webhook.env` 中的 `GITHUB_WEBHOOK_SECRET` 一致,事件只选择 push。
|
||||||
|
|
||||||
| Secret | 说明 |
|
服务器 Webhook 服务使用 `/etc/weread-webhook.env`,至少配置 `GITHUB_WEBHOOK_SECRET`、`GITHUB_REPOSITORY=KingHWJ/WeReadBoard`、`DEPLOY_PROJECT_DIR` 和 `DEPLOY_SCRIPT`。代码内固定只允许 `master`,不接受环境变量覆盖。Nginx 将 HTTPS 请求转发至本机 `127.0.0.1:9001`;Webhook 会校验 HMAC-SHA256、仓库和分支,并在已有部署运行时拒绝重复启动。
|
||||||
|---|---|
|
|
||||||
| `SERVER_HOST` | 服务器 IP |
|
|
||||||
| `SERVER_PORT` | SSH 端口,未配置时按 22 处理 |
|
|
||||||
| `SERVER_USER` | SSH 登录用户 |
|
|
||||||
| `SERVER_SSH_KEY` | GitHub Actions 登录服务器用的私钥 |
|
|
||||||
|
|
||||||
可选配置放在 `Settings -> Secrets and variables -> Actions -> Variables`:
|
|
||||||
|
|
||||||
| Variable | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `DEPLOY_PATH` | 可选,默认 `/opt/weread-board/WeReadBoard` |
|
|
||||||
| `SERVICE_NAME` | 可选,默认 `weread.service` |
|
|
||||||
|
|
||||||
如果在服务器手动部署,可执行:
|
如果在服务器手动部署,可执行:
|
||||||
|
|
||||||
@@ -173,8 +161,8 @@ node --check static/js/weapi.js
|
|||||||
|
|
||||||
当前书架电子书列表每页显示 48 本,页码导航位于列表下方;翻页会替换当前页内容。右侧排序框提供最近阅读、书名升序、书名降序和作者升序,置顶书籍始终优先;切换书架状态或排序会回到第 1 页。此功能使用已加载的书架数据,不需要数据库迁移。
|
当前书架电子书列表每页显示 48 本,页码导航位于列表下方;翻页会替换当前页内容。右侧排序框提供最近阅读、书名升序、书名降序和作者升序,置顶书籍始终优先;切换书架状态或排序会回到第 1 页。此功能使用已加载的书架数据,不需要数据库迁移。
|
||||||
|
|
||||||
## master 自动部署
|
## master Webhook 自动部署
|
||||||
|
|
||||||
`master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。
|
`master` 分支 push 后由 GitHub Webhook 通知服务器。服务器校验签名后,在固定项目目录执行根目录 `deploy.sh`,完成拉取 `origin/master`、加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master Webhook 自动部署](deploy-master.md)。
|
||||||
|
|
||||||
本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
|
本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
|
||||||
+12
-1
@@ -73,7 +73,18 @@ sudo journalctl -u weread.service -n 100 --no-pager
|
|||||||
|
|
||||||
### 检查部署分支
|
### 检查部署分支
|
||||||
|
|
||||||
自动部署只允许 `master`。如果 Actions 没有启动,先确认提交是否已经合并并推送到 `master`;仅推送 `dev` 不会部署。服务器部署日志中的拉取目标也必须是 `origin/master`,出现其他分支时应停止发布并检查工作流与 `deploy.sh`。
|
自动部署只允许 `master`。先检查 GitHub Webhook 的 Recent Deliveries、服务器 `weread-webhook.service` 日志和 Nginx HTTPS 转发;仅推送 `dev` 不会部署。服务器部署日志中的拉取目标必须是 `origin/master`,出现其他分支时应停止发布并检查 Webhook 环境变量与 `deploy.sh`。
|
||||||
|
|
||||||
|
Webhook 排查命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status weread-webhook.service --no-pager
|
||||||
|
sudo journalctl -u weread-webhook.service -n 100 --no-pager
|
||||||
|
sudo nginx -t
|
||||||
|
curl -fsS https://laohang.space/health
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub 显示签名错误时,确认 Webhook Secret 与 `/etc/weread-webhook.env` 完全一致;显示 202 但没有部署时,确认仓库全名、`refs/heads/master` 和部署锁状态。不要通过请求体传入 Shell 命令。
|
||||||
|
|
||||||
## 2026-09-14:接口调整后页面请求失败
|
## 2026-09-14:接口调整后页面请求失败
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ if ! grep -q '^ - master$' "$WORKFLOW"; then
|
|||||||
echo "部署工作流尚未监听 master"
|
echo "部署工作流尚未监听 master"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if grep -q '^ - dev$' "$WORKFLOW"; then
|
if ! grep -q 'deploy_webhook.py' "$ROOT_DIR/deploy/weread-webhook.service"; then
|
||||||
echo "部署工作流仍监听 dev"
|
echo "Webhook systemd 服务未配置"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! grep -q '^ group: deploy-master$' "$WORKFLOW"; then
|
if ! grep -q 'DEFAULT_BRANCH = "master"' "$ROOT_DIR/deploy_webhook.py"; then
|
||||||
echo "部署并发组尚未切换到 master"
|
echo "Webhook 未固定 master 分支"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! grep -q '^ DEPLOY_BRANCH: master$' "$WORKFLOW"; then
|
if grep -q 'SERVER_SSH_KEY\|ssh -i' "$WORKFLOW"; then
|
||||||
echo "手动部署分支尚未固定为 master"
|
echo "验证工作流仍包含服务器 SSH 部署"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from deploy_webhook import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.process = None
|
||||||
|
self.app = create_app({
|
||||||
|
"TESTING": True,
|
||||||
|
"WEBHOOK_SECRET": "webhook-secret",
|
||||||
|
"REPOSITORY": "KingHWJ/WeReadBoard",
|
||||||
|
"BRANCH": "master",
|
||||||
|
"PROJECT_DIR": "/srv/weread",
|
||||||
|
"DEPLOY_SCRIPT": "deploy.sh",
|
||||||
|
})
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def signed(payload):
|
||||||
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
signature = hmac.new(b"webhook-secret", body, hashlib.sha256).hexdigest()
|
||||||
|
return body, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-GitHub-Event": "push",
|
||||||
|
"X-Hub-Signature-256": f"sha256={signature}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_health_is_available(self):
|
||||||
|
self.assertEqual(self.client.get("/health").status_code, 200)
|
||||||
|
|
||||||
|
def test_invalid_signature_is_rejected(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/github/webhook",
|
||||||
|
data=b"{}",
|
||||||
|
headers={"X-GitHub-Event": "push", "X-Hub-Signature-256": "sha256=bad"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
@patch("deploy_webhook.subprocess.Popen")
|
||||||
|
def test_only_matching_master_push_starts_deployment(self, popen):
|
||||||
|
payload = {"ref": "refs/heads/master", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
|
||||||
|
body, headers = self.signed(payload)
|
||||||
|
response = self.client.post("/github/webhook", data=body, headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
self.assertEqual(response.json["status"], "accepted")
|
||||||
|
popen.assert_called_once()
|
||||||
|
self.assertEqual(popen.call_args.args[0], ["/srv/weread/deploy.sh"])
|
||||||
|
|
||||||
|
@patch("deploy_webhook.subprocess.Popen")
|
||||||
|
def test_dev_push_does_not_start_deployment(self, popen):
|
||||||
|
payload = {"ref": "refs/heads/dev", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
|
||||||
|
body, headers = self.signed(payload)
|
||||||
|
response = self.client.post("/github/webhook", data=body, headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
popen.assert_not_called()
|
||||||
|
|
||||||
|
@patch("deploy_webhook.subprocess.Popen")
|
||||||
|
def test_duplicate_push_is_not_started_while_deploying(self, popen):
|
||||||
|
popen.return_value.poll.return_value = None
|
||||||
|
payload = {"ref": "refs/heads/master", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
|
||||||
|
body, headers = self.signed(payload)
|
||||||
|
self.assertEqual(self.client.post("/github/webhook", data=body, headers=headers).json["status"], "accepted")
|
||||||
|
response = self.client.post("/github/webhook", data=body, headers=headers)
|
||||||
|
self.assertEqual(response.json["status"], "already_deploying")
|
||||||
|
popen.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user