diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 9756a214..220c5965 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -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 时是「任一完成即触发」而不是「全部完成」,所以先到的那条触发进来时 @@ -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 这个动作」。 @@ -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 的结论 @@ -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' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c80a57a3..e8ac1e9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs/production-deployment.md b/docs/production-deployment.md new file mode 100644 index 00000000..bf9bb808 --- /dev/null +++ b/docs/production-deployment.md @@ -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`,不会仅因为自动部署逻辑合入而发布一个新版本。 diff --git a/scripts/release_deploy.py b/scripts/release_deploy.py new file mode 100644 index 00000000..ca578203 --- /dev/null +++ b/scripts/release_deploy.py @@ -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", + ) + + +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() diff --git a/tests/test_release_deploy.py b/tests/test_release_deploy.py new file mode 100644 index 00000000..7daf8af5 --- /dev/null +++ b/tests/test_release_deploy.py @@ -0,0 +1,188 @@ +"""Release selection and production failure boundaries; no live services are used.""" +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location( + "release_deploy", Path(__file__).resolve().parents[1] / "scripts/release_deploy.py" +) +deploy = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(deploy) +SHA = "a" * 40 +NEW_SHA = "b" * 40 + + +class ReleaseSelectionTests(unittest.TestCase): + def select(self, tag_sha=SHA, **release): + responses = [SHA, tag_sha] + if tag_sha == SHA: + responses.append(json.dumps({"isDraft": False, "isPrerelease": False, **release})) + with tempfile.TemporaryDirectory() as directory: + version = Path(directory) / "VERSION" + version.write_text("1.2.3\n") + with patch.object(deploy, "read", side_effect=responses): + return deploy.release_target(version) + + def test_release_commit_produces_exact_tag_and_sha(self): + self.assertEqual(self.select(), {"tag": "v1.2.3", "sha": SHA}) + + def test_later_commits_with_unchanged_version_do_not_deploy(self): + self.assertEqual(self.select(tag_sha=NEW_SHA), {}) + + def test_draft_and_prerelease_do_not_deploy(self): + self.assertEqual(self.select(isDraft=True), {}) + self.assertEqual(self.select(isPrerelease=True), {}) + + def test_missing_tag_does_not_deploy(self): + with tempfile.TemporaryDirectory() as directory: + version = Path(directory) / "VERSION" + version.write_text("1.2.3") + with patch.object(deploy, "read", side_effect=[SHA, subprocess.CalledProcessError(1, "git")]): + self.assertEqual(deploy.release_target(version), {}) + + +class ProductionTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.state = Path(self.tmp.name) / "deployed.json" + self.public = Path(self.tmp.name) / "public.json" + self.commands = [] + self.dirty = "" + self.tag_sha = SHA + self.failure = None + + def read(self, *args): + if args[:2] == ("git", "status"): + return self.dirty + if args[:2] == ("git", "rev-parse"): + return self.tag_sha + raise AssertionError(args) + + def execute(self, *args): + self.commands.append(args) + if self.failure and self.failure(args): + raise subprocess.CalledProcessError(1, args) + + def run_deploy(self, tag="v1.2.3", sha=SHA): + with patch.object(deploy, "read", side_effect=self.read), patch.object( + deploy, "execute", side_effect=self.execute + ): + deploy.deploy(tag, sha, self.state, self.public) + + def test_success_records_release_only_after_health_check(self): + self.run_deploy() + self.assertEqual(json.loads(self.state.read_text()), {"tag": "v1.2.3", "sha": SHA}) + self.assertEqual(self.public.read_text(), self.state.read_text()) + self.assertIn(("git", "checkout", "--detach", SHA), self.commands) + self.assertEqual(self.commands[-1][0], "curl") + + def test_tag_mismatch_prevents_checkout_and_build(self): + self.tag_sha = NEW_SHA + with self.assertRaisesRegex(ValueError, "tag"): + self.run_deploy() + self.assertFalse(any(c[:2] == ("git", "checkout") or c[0] == "docker" for c in self.commands)) + + def test_dirty_checkout_is_not_overwritten(self): + self.dirty = " M docker-compose.yml" + with self.assertRaisesRegex(ValueError, "modified"): + self.run_deploy() + self.assertEqual(self.commands, []) + + def test_commit_outside_main_cannot_be_deployed(self): + self.failure = lambda c: c[:2] == ("git", "merge-base") + with self.assertRaises(subprocess.CalledProcessError): + self.run_deploy() + self.assertFalse(any(c[0] == "docker" for c in self.commands)) + + def test_image_build_failure_never_restarts_services(self): + self.failure = lambda c: "build" in c + with self.assertRaises(subprocess.CalledProcessError): + self.run_deploy() + self.assertFalse(any("up" in c or "run" in c for c in self.commands)) + self.assertFalse(self.state.exists()) + + def test_frontend_compile_failure_never_restarts_backend(self): + self.failure = lambda c: "run" in c + with self.assertRaises(subprocess.CalledProcessError): + self.run_deploy() + self.assertFalse(any("up" in c for c in self.commands)) + self.assertFalse(self.state.exists()) + + def test_health_failure_preserves_last_successful_version(self): + previous = json.dumps({"tag": "v1.2.2", "sha": NEW_SHA}) + self.state.write_text(previous) + self.public.write_text(previous) + self.failure = lambda c: c[0] == "curl" + with self.assertRaises(subprocess.CalledProcessError): + self.run_deploy() + self.assertEqual(self.state.read_text(), previous) + self.assertEqual(self.public.read_text(), previous) + + def test_old_release_rerun_cannot_roll_back_newer_production(self): + self.state.write_text(json.dumps({"tag": "v1.3.0", "sha": NEW_SHA})) + self.run_deploy() + self.assertFalse(any(c[0] == "docker" for c in self.commands)) + + def test_successful_release_rerun_only_checks_health(self): + self.state.write_text(json.dumps({"tag": "v1.2.3", "sha": SHA})) + self.run_deploy() + self.assertFalse(any(c[0] == "docker" for c in self.commands)) + self.assertEqual(self.commands[-1][0], "curl") + + def test_invalid_inputs_do_not_reach_commands(self): + for tag, sha in [("v1;echo bad", SHA), ("v1.2.3", "main")]: + with self.assertRaises(ValueError): + self.run_deploy(tag, sha) + self.assertEqual(self.commands, []) + + +class GitIntegrationTests(unittest.TestCase): + def test_deploy_pins_tag_and_preserves_untracked_server_configuration(self): + root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + remote = temporary / "remote.git" + checkout = temporary / "production" + + def git(*args, cwd=root): + return subprocess.check_output( + ["git", *map(str, args)], cwd=cwd, text=True, stderr=subprocess.DEVNULL + ).strip() + + git("clone", "--bare", "--shared", root, remote) + sha = git("rev-parse", "HEAD", cwd=remote) + git("update-ref", "refs/heads/main", sha, cwd=remote) + git("tag", "v99.0.1", sha, cwd=remote) + git("clone", "--shared", "--branch", "main", remote, checkout) + # origin/main can advance after a tag is selected; the deployed tree + # must still be the tag, never whatever a later pull would return. + (checkout / "release-fixture.txt").write_text("later main commit") + git("add", "release-fixture.txt", cwd=checkout) + git("-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "commit", "-m", "advance fixture main", cwd=checkout) + git("push", "origin", "main", cwd=checkout) + (checkout / ".env").write_text("KEEP=local\n") + (checkout / "docker-compose.override.yml").write_text("services: {}\n") + + def execute(*args): + if args[0] == "git": + subprocess.run(args, cwd=checkout, check=True, capture_output=True) + + with patch.object(deploy, "read", side_effect=lambda *args: git(*args[1:], cwd=checkout)), patch.object( + deploy, "execute", side_effect=execute + ): + deploy.deploy("v99.0.1", sha, temporary / "state.json", temporary / "public.json") + self.assertEqual(git("rev-parse", "HEAD", cwd=checkout), sha) + self.assertFalse((checkout / "release-fixture.txt").exists()) + self.assertEqual((checkout / ".env").read_text(), "KEEP=local\n") + self.assertEqual((checkout / "docker-compose.override.yml").read_text(), "services: {}\n") + + +if __name__ == "__main__": + unittest.main()