Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 81 additions & 5 deletions .github/workflows/version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ name: Version

# 版本的完整生命周期在这一个文件里:检查 VERSION 变更 → 自动 tag 与 Release → 部署。
#
# 两个 job 而不是一个,因为两件事的触发时机不同:
# 三个 job 的触发时机不同:
# gate 在 PR 上跑。VERSION 非法或回退要在合并前就红 —— 合进 main 之后才炸的话,
# 坏的是一次已经在 main 上的提交,而部署只认 Release。
# release 等**全部必需 CI**都绿之后跑。直接挂 push 会在测试未过时就发版(#334 第四节)。
# deploy 只接 release 确认的正式版本,不随普通 main 更新部署。
on:
pull_request:
# 列两条 workflow 时是「任一完成即触发」而不是「全部完成」,所以先到的那条触发进来时
Expand Down Expand Up @@ -46,6 +47,7 @@ jobs:
run: |
python -m pip install --quiet pytest==8.3.4
python -m pytest -q tests/test_version_gate.py
python -m unittest discover -s tests -p test_release_deploy.py

- name: 校验 VERSION
# 比对的是**基点上的** VERSION:单调性约束的是「改 VERSION 这个动作」。
Expand All @@ -64,9 +66,16 @@ jobs:
release:
# workflow_run 在 CI 失败时同样会触发,故必须自己判 conclusion。
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
github.repository == '1024XEngineer/Windup' &&
((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_run' && github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
github.event.workflow_run.conclusion == 'success'))
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.target.outputs.tag }}
sha: ${{ steps.target.outputs.sha }}
permissions:
contents: write
actions: read # 要读同一提交上其它 workflow 的结论
Expand Down Expand Up @@ -160,6 +169,73 @@ jobs:
fi
gh release create "$NEXT" --title "$NEXT" --notes-file notes.md

- name: 确认本次部署的正式 Release
id: target
if: steps.ci.outputs.ok == 'true'
env:
GH_TOKEN: ${{ github.token }}
# 同一个 Release 的失败部署可重跑; VERSION 未变的后续提交不会部署。
run: python3 scripts/release_deploy.py target >> "$GITHUB_OUTPUT"

# ── 3. CD ────────────────────────────────────────────────────────────────
# 部署按 #653 单独处理:它需要 Environment Secrets 与 required reviewer,且本仓
# 当前没有任何部署 workflow。接进来的形态是本文件再加一个 needs: release 的 job。
# 接在同一工作流中:GITHUB_TOKEN 创建 Release 不会触发新的 release 工作流。
deploy:
needs: release
if: needs.release.outputs.tag != ''
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
environment:
name: Production
url: https://windup.xin
concurrency:
group: windup-production-deploy
cancel-in-progress: false
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.sha }}
persist-credentials: false

- name: Install SSH client
run: sudo apt-get update -qq && sudo apt-get install -y sshpass

- name: Deploy Release
env:
DEPLOY_HOST: ${{ secrets.WINDUP_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.WINDUP_DEPLOY_USER }}
SSHPASS: ${{ secrets.WINDUP_DEPLOY_PASSWORD }}
KNOWN_HOSTS: ${{ secrets.WINDUP_DEPLOY_KNOWN_HOSTS }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
RELEASE_SHA: ${{ needs.release.outputs.sha }}
run: |
set -euo pipefail
: "${DEPLOY_HOST:?Missing WINDUP_DEPLOY_HOST}"
: "${DEPLOY_USER:?Missing WINDUP_DEPLOY_USER}"
: "${SSHPASS:?Missing WINDUP_DEPLOY_PASSWORD}"
: "${KNOWN_HOSTS:?Missing WINDUP_DEPLOY_KNOWN_HOSTS}"
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]]
umask 077
printf '%s\n' "$KNOWN_HOSTS" > "$RUNNER_TEMP/windup-known-hosts"
sshpass -e ssh -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$RUNNER_TEMP/windup-known-hosts" \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
-o ConnectTimeout=15 -o ServerAliveInterval=30 -o ServerAliveCountMax=6 \
"$DEPLOY_USER@$DEPLOY_HOST" \
"cd /root/workspace/Windup && python3 - deploy '$RELEASE_TAG' '$RELEASE_SHA'" \
< scripts/release_deploy.py

- name: Verify public service
env:
RELEASE_TAG: ${{ needs.release.outputs.tag }}
RELEASE_SHA: ${{ needs.release.outputs.sha }}
run: |
set -euo pipefail
curl --fail --silent --show-error --retry 6 --retry-all-errors --max-time 15 \
"https://windup.xin/api/health?release=$RELEASE_SHA"
curl --fail --silent --show-error --retry 6 --retry-all-errors --max-time 15 \
"https://windup.xin/release.json?release=$RELEASE_SHA" \
| jq -e --arg tag "$RELEASE_TAG" --arg sha "$RELEASE_SHA" \
'.tag == $tag and .sha == $sha'
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ git checkout -b <分支名>

- Tag 统一使用 `vX.Y.Z`;每个 Milestone 结束发一次 Release,描述列清本轮交付。
- 生产部署必须先发版本:打 Tag → 创建 Release(写清变更内容)→ 部署。**任何部署只认 Release。**
- 生产部署暂为手动,由发布人唯一执行;CD 自动化另立 Issue 跟进。
- 正式 Release 创建后,Version 工作流自动部署该 tag 对应的提交到生产,并检查服务健康和线上版本。普通 main 更新不部署;配置和失败重试见 [生产部署](docs/production-deployment.md)。

**怎么发一版**:把根目录 `VERSION` 改成新版本号,单独提一个 PR。合入且 Backend CI
通过后,自动打 `v{VERSION}` 并创建 Release,notes 由上一个 tag 到本次提交的 commit
Expand Down
32 changes: 32 additions & 0 deletions docs/production-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 生产部署

更新 `VERSION` 的 PR 合并后,Version 工作流等待 Backend CI 和 Frontend CI 通过,创建 Tag 与 Release,再自动部署该版本。发布时机仍由版本 PR 决定,不按每个普通 PR 自动上线。本流程落实 #952,取代原手动部署步骤;按本次确认的自动部署要求,不额外设置部署审批。

## 配置

GitHub `Production` Environment 保存以下 Secrets:

- `WINDUP_DEPLOY_HOST`:现有生产 SSH 主机。
- `WINDUP_DEPLOY_USER`:生产登录用户。
- `WINDUP_DEPLOY_PASSWORD`:该用户现有 SSH 密码。
- `WINDUP_DEPLOY_KNOWN_HOSTS`:通过可信连接核验的 OpenSSH known_hosts 记录,不可在工作流里临时扫描并无条件信任。

沿用服务器现有密码认证,不修改登录策略。仅上游 main 的 CI 或 main 上的手动重试可进入发版流程。部署固定使用 `/root/workspace/Windup`、Compose 项目 `windup` 和前端目录 `/var/www/react-windup`;服务器需有 Python 3、Git、curl 与支持 `up --wait` 的 Docker Compose。

## 行为与验证

部署 job 直接 `needs: release`,因为 `GITHUB_TOKEN` 创建的 Release 不会触发另一个 release 工作流。Tag 必须对应本次 CI 的确切提交,且是正式、非草稿 Release。没有新版本的提交不部署。

Actions concurrency 与服务器文件锁串行部署。服务器核对 tag、SHA 与 main 祖先关系,拒绝覆盖已跟踪的本地修改,然后切换到 Release 提交的 detached HEAD。`.env` 与未跟踪的 Compose override 保留,不清理文件、镜像或数据卷。

镜像构建成功后,前端先在容器临时目录完成编译,再复制到 nginx 目录;保留旧哈希资源,避免已打开的页面丢失资源。之后重建 backend/worker 并等待健康检查。该流程不是零停机或原子切换,前后端切换间可能存在短暂版本差异;worker 目前没有业务级健康探针,Compose 只能确认进程运行。

本机健康检查成功后写入 `.git/windup-deployed.json` 和公开的 `/release.json` (`tag`、`sha`)。工作流还核对公开健康接口及版本标记,结果在 Actions 与 Production deployment 记录中可查。标记仅表示最后一次通过本机检查的发布,不是服务此后持续健康的保证。

## 失败与重试

任何构建、启动或探测错误都会让 job 失败。构建失败不会重启后端;启动或探测失败可能已有部分服务更新,不会声称自动恢复旧版,也不会尝试回退数据库。

优先在原 Version run 中选择 **Re-run failed jobs**,保留原版本与 SHA。重复执行已成功的版本只检查健康;旧版本不能覆盖记录中的更新版本。也可以重跑原发布 run 的全部 jobs。不要在 main 已前进后用无参数 workflow_dispatch 期待重试旧版,它只检查所选 main 提交。

需要回退应用时,通过 PR revert 有问题的改动,提升 patch 版本再发布。修复服务器本地变更冲突后重试,不要使用 `git clean` 或删除数据卷。此 PR 不改 `VERSION`,不会仅因为自动部署逻辑合入而发布一个新版本。
111 changes: 111 additions & 0 deletions scripts/release_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Deploy a published Release's exact commit using the existing production Compose."""
from __future__ import annotations

import fcntl
import json
from pathlib import Path
import re
import subprocess
import sys


def read(*args: str) -> str:
return subprocess.check_output(args, text=True).strip()


def execute(*args: str) -> None:
subprocess.run(args, check=True)


def version(tag: str) -> tuple[int, ...]:
if not re.fullmatch(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", tag):
raise ValueError("Invalid release tag")
return tuple(map(int, tag[1:].split(".")))


def release_target(path: Path = Path("VERSION")) -> dict[str, str]:
tag = "v" + path.read_text().strip()
version(tag)
sha = read("git", "rev-parse", "HEAD")
try:
tag_sha = read("git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}")
except subprocess.CalledProcessError:
return {}
# A later main commit can retain VERSION without representing a new release.
if sha != tag_sha:
return {}
release = json.loads(read("gh", "release", "view", tag, "--json", "isDraft,isPrerelease"))
if release["isDraft"] or release["isPrerelease"]:
return {}
return {"tag": tag, "sha": sha}


def health() -> None:
execute(
"curl", "--fail", "--silent", "--show-error", "--retry", "12",
"--retry-all-errors", "--retry-delay", "5", "--max-time", "10",
"http://127.0.0.1:8000/api/health",
Comment thread
xiaocheny214 marked this conversation as resolved.
)


def deploy(tag: str, sha: str, state: Path, public: Path) -> None:
desired_version = version(tag)
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("Invalid release SHA")
if read("git", "status", "--porcelain", "--untracked-files=no"):
raise ValueError("Tracked production files are modified; refusing to overwrite")
execute("git", "fetch", "origin", "main", "--tags")
if read("git", "rev-parse", f"refs/tags/{tag}^{{commit}}") != sha:
raise ValueError("Release tag does not match the approved SHA")
execute("git", "merge-base", "--is-ancestor", sha, "origin/main")
if state.exists():
previous = json.loads(state.read_text())
if version(previous["tag"]) > desired_version:
print(f"Skip obsolete release {tag}; production is {previous['tag']}")
return
if previous == {"tag": tag, "sha": sha} and read("git", "rev-parse", "HEAD") == sha:
health()
print(f"{tag} already deployed")
return

# Detached HEAD pins the release even when origin/main has moved ahead.
# Git refuses conflicting untracked files; .env and override are never cleaned.
execute("git", "checkout", "--detach", sha)
compose = ("docker", "compose", "-p", "windup")
execute(*compose, "build", "backend", "worker", "frontend")
# The image's normal CMD builds directly into nginx's directory. Compile in a
# container-local directory first so a failed build cannot empty the live site.
# Keep old hashed assets for browsers that still hold the previous index.
execute(
*compose, "run", "--rm", "--no-deps", "frontend", "sh", "-ec",
"npm run build -- --outDir /tmp/windup-release-dist && "
"cp -a /tmp/windup-release-dist/. /var/www/react-windup/",
)
execute(*compose, "up", "-d", "--no-build", "--wait", "--wait-timeout", "120", "backend", "worker")
health()
record = json.dumps({"tag": tag, "sha": sha}) + "\n"
# These markers describe the last verified release, not a partially failed one.
for path in (public, state):
temporary = path.with_suffix(".tmp")
temporary.write_text(record)
temporary.replace(path)
print(f"Deployed {tag} ({sha})")


def main() -> None:
if sys.argv[1:] == ["target"]:
for key, value in release_target().items():
print(f"{key}={value}")
return
if len(sys.argv) != 4 or sys.argv[1] != "deploy":
raise ValueError("Usage: release_deploy.py target | deploy TAG SHA")
git_dir = Path(read("git", "rev-parse", "--absolute-git-dir"))
# Serializes direct manual invocations with workflow deployments.
with (git_dir / "windup-deploy.lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
deploy(sys.argv[2], sys.argv[3], git_dir / "windup-deployed.json",
Path("/var/www/react-windup/release.json"))


if __name__ == "__main__":
main()
Loading
Loading