From 41ac38aaf7af27125130883271fc7fb0719662d7 Mon Sep 17 00:00:00 2001 From: liukun Date: Fri, 25 Sep 2026 15:42:34 +0800 Subject: [PATCH] ci/cd: GitHub Actions CI + pull-based CD on seed-SER9 CI (.github/workflows/ci.yml), on every PR and push to main: - check: npm ci, lint, UI tests, build, server tests - docker: build the image, start it, smoke /healthz, /, /docs (200) and /api/skills (401 without login) CD (deploy/cd/ash-cd.sh + systemd user timer, every 2 min): - deploys main only when its "check" and "docker" runs succeeded - backup DB + skills_files, git archive from its own mirror, build ash:, derive compose by swapping only the image tag, switch - smoke: /healthz, /, /docs 200, /api/skills 401, skill count unchanged, no migration/uncaught errors in logs; public /healthz only warns - automatic rollback on smoke failure; failed commits are never retried - reports to GitHub Deployments (production); keeps the last 10 images - manual: ash-cd status | deploy [--force] | rollback [] - pull-based on purpose: the repo is public, so no self-hosted runner Rehearsed end to end on a staging container with a copy of production data: deploy, smoke-failure rollback, manual rollback, poll-waits-for-CI. docs/deploy.md: CI/CD is now the standard path; the manual five steps remain as the fallback. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 65 +++++++++++ deploy/cd/ash-cd.service | 10 ++ deploy/cd/ash-cd.sh | 244 +++++++++++++++++++++++++++++++++++++++ deploy/cd/ash-cd.timer | 10 ++ deploy/cd/install.sh | 27 +++++ docs/deploy.md | 54 ++++++++- 6 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 deploy/cd/ash-cd.service create mode 100755 deploy/cd/ash-cd.sh create mode 100644 deploy/cd/ash-cd.timer create mode 100755 deploy/cd/install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..879d3f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +# CI:每个 PR 和 main 上的每次推送都要过这两关。 +# CD 不在这里——seed-SER9 上的 ash-cd 定时轮询 main,只部署这两个检查都通过的提交(见 deploy/cd/)。 +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + # 名字即检查项名称,ash-cd 按 "check" / "docker" 判定能否上线,改名要同步改 deploy/cd/ash-cd.sh + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + client/package-lock.json + - run: npm ci --no-audit --no-fund + - run: npm ci --prefix client --no-audit --no-fund + - name: Lint + run: npm run lint + - name: UI tests + run: npm run test:ui + - name: Build + run: npm run build + - name: Server tests + run: npm run test:server + + docker: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - name: Build image + run: docker build -t ash:ci . + - name: Smoke test the container + run: | + docker run -d --name ash-ci -p 9444:9444 -e ASH_SEED=0 ash:ci + for i in $(seq 1 30); do + curl -fsS http://127.0.0.1:9444/healthz && break + sleep 2 + done + set -x + curl -fsS http://127.0.0.1:9444/healthz + test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9444/)" = 200 + test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9444/docs)" = 200 + # 所有数据接口都必须要求登录 + test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9444/api/skills)" = 401 + - name: Container logs + if: always() + run: docker logs ash-ci || true diff --git a/deploy/cd/ash-cd.service b/deploy/cd/ash-cd.service new file mode 100644 index 0000000..cb507b6 --- /dev/null +++ b/deploy/cd/ash-cd.service @@ -0,0 +1,10 @@ +[Unit] +Description=AnotherSkillHub CD: deploy CI-green commits on main (ash-cd poll) +After=network-online.target docker.service + +[Service] +Type=oneshot +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=%h/services/skillhub-cd/bin/ash-cd poll +TimeoutStartSec=30min +Nice=5 diff --git a/deploy/cd/ash-cd.sh b/deploy/cd/ash-cd.sh new file mode 100755 index 0000000..c11e849 --- /dev/null +++ b/deploy/cd/ash-cd.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# ash-cd — seed-SER9 上的拉取式持续部署。 +# +# systemd 定时器每 2 分钟执行一次 `ash-cd poll`:main 出现新提交、且它的 CI 检查(check、docker) +# 全部通过时,按部署手册的顺序上线:备份 DB 与技能文件 → 构建 ash: → 切换容器 → 冒烟。 +# 冒烟失败自动切回上一个版本。部署进度回写到 GitHub Deployments(environment: production)。 +# +# 用法: +# ash-cd poll 定时器入口;没有新的可部署提交时什么也不做 +# ash-cd deploy 立即部署某个提交(仍要求它的 CI 已通过;--force 跳过 CI 检查) +# ash-cd rollback [] 切回上一个(或指定的)已发布版本 +# ash-cd status 当前版本、main 最新提交及其 CI 状态、最近的部署记录 +# +# 只依赖本机的 docker、git、gh(已登录)、curl、flock。代码来自自己维护的镜像仓库, +# 与任何工作目录 / worktree 当前在哪个分支无关。 +set -euo pipefail + +REPO="${ASH_CD_REPO:-Arismemo/AnotherSkillHub}" +SERVICES="${ASH_CD_SERVICES:-$HOME/services}" +RELEASES="$SERVICES/skillhub-releases" +CD_HOME="${ASH_CD_HOME:-$SERVICES/skillhub-cd}" +MIRROR="$CD_HOME/repo.git" +STATE="$CD_HOME/state" +HISTORY="$CD_HOME/history.log" +LOCAL_URL="${ASH_CD_LOCAL_URL:-http://127.0.0.1:9444}" +# 以下三项只为在预发环境里演练 CD 时覆盖;生产保持默认 +CONTAINER="${ASH_CD_CONTAINER:-ash}" # 容器名 +PROJECT="${ASH_CD_PROJECT:-ash}" # docker compose -p +IMAGE="${ASH_CD_IMAGE:-ash}" # 镜像仓库名,tag 为 7 位短 sha +GH_DEPLOYMENTS="${ASH_CD_GH_DEPLOYMENTS:-1}" # 0 = 不写 GitHub Deployments(演练用) +PUBLIC_URL="${ASH_CD_PUBLIC_URL:-https://ash.709970.xyz}" +REQUIRED_CHECKS=(check docker) # 与 .github/workflows/ci.yml 的 job 名一致 +KEEP_IMAGES=10 # 磁盘紧张:只保留最近 10 个 ash 镜像(当前版本永远保留) + +mkdir -p "$CD_HOME" "$STATE" + +log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +current_tag() { docker inspect "$CONTAINER" --format '{{.Config.Image}}' 2>/dev/null | sed "s#^$IMAGE:##"; } + +# 在运行中的容器里数技能行数:部署前后应当一致(迁移不许丢数据) +db_count() { + docker exec "$CONTAINER" node -e ' + const D = require("better-sqlite3"); + const db = new D("/app/data/another-skillhub.db", { readonly: true }); + console.log(db.prepare("SELECT COUNT(*) AS c FROM skills").get().c);' +} + +sync_mirror() { + if [ ! -d "$MIRROR" ]; then git clone -q --mirror "https://github.com/$REPO.git" "$MIRROR"; fi + git -C "$MIRROR" fetch -q --prune origin +} + +# 输出 success / failure / pending +ci_state() { + local sha=$1 name result + for name in "${REQUIRED_CHECKS[@]}"; do + # 同名检查可能被重跑多次,取最近开始的那一次 + result=$(gh api "repos/$REPO/commits/$sha/check-runs?check_name=$name&per_page=100" \ + --jq '[.check_runs[]] | sort_by(.started_at) | last | if . == null then "missing" else "\(.status)/\(.conclusion)" end' 2>/dev/null) \ + || { echo pending; return; } + case "$result" in + completed/success) ;; + completed/*) echo failure; return ;; + *) echo pending; return ;; + esac + done + echo success +} + +# ——— GitHub Deployments(失败不影响部署本身)——— +DEPLOY_ID="" +gh_deployment() { + [ "$GH_DEPLOYMENTS" = 1 ] || return 0 + DEPLOY_ID=$(gh api -X POST "repos/$REPO/deployments" --jq .id --input - 2>/dev/null </dev/null 2>&1 || true +} + +wait_healthy() { + local i + for i in $(seq 1 45); do + curl -fsS -m 3 "$LOCAL_URL/healthz" >/dev/null 2>&1 && return 0 + sleep 2 + done + return 1 +} + +http_code() { curl -s -o /dev/null -m 10 -w '%{http_code}' "$1"; } + +smoke() { + local expected_count=$1 started=$2 count + wait_healthy || { log "smoke: /healthz 90 秒内没有就绪"; return 1; } + [ "$(http_code "$LOCAL_URL/")" = 200 ] || { log "smoke: 首页不是 200"; return 1; } + [ "$(http_code "$LOCAL_URL/docs")" = 200 ] || { log "smoke: /docs 不是 200"; return 1; } + [ "$(http_code "$LOCAL_URL/api/skills")" = 401 ] || { log "smoke: /api/skills 未登录没有返回 401"; return 1; } + count=$(db_count) || { log "smoke: 读不出技能数"; return 1; } + [ "$count" = "$expected_count" ] || { log "smoke: 技能数 $expected_count → $count"; return 1; } + if docker logs --since "$started" "$CONTAINER" 2>&1 | grep -qiE '^migration:|unhandled|uncaught'; then + log "smoke: 容器日志里有迁移或未捕获错误"; docker logs --since "$started" "$CONTAINER" 2>&1 | tail -20 >&2; return 1 + fi + # 公网入口经过独立的隧道和网关,不通只告警、不回滚 + [ "$(http_code "$PUBLIC_URL/healthz")" = 200 ] || log "WARN: 公网 $PUBLIC_URL/healthz 不通,检查 skillhub-tunnel 服务" + log "smoke ok:技能数 $count" +} + +switch_to() { + local tag=$1 + [ -f "$RELEASES/$tag/compose.deploy.yml" ] || die "找不到 $RELEASES/$tag/compose.deploy.yml" + (cd "$RELEASES/$tag" && docker compose -p "$PROJECT" -f compose.deploy.yml up -d --no-build) >&2 +} + +prune_images() { + local current; current=$(current_tag) + docker images "$IMAGE" --format '{{.CreatedAt}}\t{{.Tag}}' | sort -r | cut -f2 \ + | { grep -vx "$current" || true; } | tail -n "+$KEEP_IMAGES" | while read -r tag; do + docker rmi "$IMAGE:$tag" >/dev/null 2>&1 && log "清理旧镜像 $IMAGE:$tag" || true + done +} + +# 部署中途失败:回写 GitHub 状态并返回非零(调用方据此标记该提交,不再自动重试) +abort() { gh_status failure "$1"; log "ERROR: $1"; return 1; } + +deploy() { + local full=$1 sha=${1:0:7} old started before + old=$(current_tag) + [ -n "$old" ] || die "$CONTAINER 容器不存在;首次部署请按 docs/deploy.md 手动完成" + if [ "$sha" = "$old" ]; then log "$IMAGE:$sha 已在线上"; return 0; fi + [ -f "$RELEASES/$old/compose.deploy.yml" ] || die "当前版本 $old 没有发布目录,无法派生 compose" + log "开始部署 $sha(当前 $old)" + gh_deployment "$full" + gh_status in_progress "building $IMAGE:$sha" + + # 1. 备份:DB 与技能文件(数据卷属 root,在容器里做) + docker exec "$CONTAINER" node -e "new (require('better-sqlite3'))('/app/data/another-skillhub.db').backup('/app/data/backup-before-$sha.db').then(() => console.log('db backup ok'))" >&2 \ + || { abort "数据库备份失败"; return 1; } + docker exec "$CONTAINER" tar -czf "/app/data/backup-files-before-$sha.tgz" -C /app/data skills_files \ + || { abort "技能文件备份失败"; return 1; } + before=$(db_count) || { abort "读不出部署前的技能数"; return 1; } + + # 2. 源码 → 镜像(服务器 npm install 偶发网络抖动,失败重试一次) + rm -rf "${RELEASES:?}/$sha" && mkdir -p "$RELEASES/$sha" + git -C "$MIRROR" archive --format=tar "$full" | tar -x -C "$RELEASES/$sha" || { abort "导出源码失败"; return 1; } + rm -rf "$RELEASES/$sha/data" + docker build -q -t "$IMAGE:$sha" "$RELEASES/$sha" >&2 || docker build -q -t "$IMAGE:$sha" "$RELEASES/$sha" >&2 \ + || { abort "镜像构建失败"; return 1; } + + # 3. compose 只替换镜像 tag,其余(端口、卷、ASH_REGISTRATION 等)原样继承 + sed "s#image: $IMAGE:$old#image: $IMAGE:$sha#" "$RELEASES/$old/compose.deploy.yml" > "$RELEASES/$sha/compose.deploy.yml" + grep -q "image: $IMAGE:$sha" "$RELEASES/$sha/compose.deploy.yml" || { abort "compose 替换镜像 tag 失败"; return 1; } + + # 4. 切换 + 冒烟,失败回滚 + started=$(date -u +%Y-%m-%dT%H:%M:%SZ) + switch_to "$sha" || log "compose up 报错,交给冒烟判定" + if smoke "$before" "$started"; then + gh_status success "$IMAGE:$sha live" + printf '%s deployed %s (from %s)\n' "$(date '+%F %T')" "$sha" "$old" >> "$HISTORY" + log "✅ $IMAGE:$sha 已上线" + prune_images + return 0 + fi + log "冒烟失败,回滚到 $IMAGE:$old" + switch_to "$old" + wait_healthy && log "已回滚到 $IMAGE:$old" || log "回滚后仍不健康,需要人工介入!" + log "如果新版本做过不兼容的迁移,还要用 backup-before-$sha.db / backup-files-before-$sha.tgz 恢复数据(见 docs/deploy.md)" + gh_status failure "smoke failed, rolled back to $IMAGE:$old" + printf '%s FAILED %s (rolled back to %s)\n' "$(date '+%F %T')" "$sha" "$old" >> "$HISTORY" + return 1 +} + +with_lock() { + exec 9>"$CD_HOME/lock" + flock -n 9 || { log "另一个部署正在进行"; exit 0; } +} + +cmd_poll() { + with_lock + sync_mirror + local head sha + head=$(git -C "$MIRROR" rev-parse refs/heads/main) + sha=${head:0:7} + [ "$sha" = "$(current_tag)" ] && return 0 + [ -f "$STATE/failed-$sha" ] && return 0 # 失败过的提交不自动重试,修好后推新提交即可 + case "$(ci_state "$head")" in + success) deploy "$head" || touch "$STATE/failed-$sha" ;; + failure) log "main@$sha 的 CI 没通过,不部署"; touch "$STATE/failed-$sha" ;; + *) log "main@$sha 等待 CI" ;; + esac +} + +cmd_deploy() { + local ref=${1:-} force=${2:-} + [ -n "$ref" ] || die "用法:ash-cd deploy [--force]" + with_lock + sync_mirror + local full; full=$(git -C "$MIRROR" rev-parse --verify "$ref^{commit}") || die "找不到提交 $ref" + if [ "$force" != "--force" ]; then + [ "$(ci_state "$full")" = success ] || die "${full:0:7} 的 CI 没有全部通过(--force 可跳过)" + fi + rm -f "$STATE/failed-${full:0:7}" + deploy "$full" +} + +cmd_rollback() { + with_lock + local target=${1:-} current; current=$(current_tag) + if [ -z "$target" ]; then + target=$(grep -E ' deployed [0-9a-f]{7} ' "$HISTORY" 2>/dev/null | tail -1 | sed -E 's/.*\(from ([0-9a-f]+)\)/\1/') + [ -n "$target" ] || die "历史里没有可回滚的版本,请指定:ash-cd rollback " + fi + docker image inspect "$IMAGE:$target" >/dev/null 2>&1 || die "镜像 $IMAGE:$target 已不存在,用 ash-cd deploy $target 重新构建" + log "回滚:$IMAGE:$current → $IMAGE:$target" + switch_to "$target" + wait_healthy || die "$IMAGE:$target 没有就绪" + touch "$STATE/failed-$current" # 防止定时器马上又把刚回滚掉的版本部署回去 + printf '%s rollback %s (from %s)\n' "$(date '+%F %T')" "$target" "$current" >> "$HISTORY" + log "✅ 已回滚到 $IMAGE:$target;$IMAGE:$current 被标记为失败,main 出现新提交前不会自动重新部署" +} + +cmd_status() { + sync_mirror + local head; head=$(git -C "$MIRROR" rev-parse refs/heads/main) + echo "线上版本:$IMAGE:$(current_tag)" + echo "main 最新:${head:0:7} CI:$(ci_state "$head")$([ -f "$STATE/failed-${head:0:7}" ] && echo ' (已标记失败,不会自动部署)')" + echo "定时器: $(systemctl --user is-active ash-cd.timer 2>/dev/null || true)" + echo "最近部署:" + tail -5 "$HISTORY" 2>/dev/null | sed 's/^/ /' || echo " (无)" +} + +case "${1:-}" in + poll) cmd_poll ;; + deploy) shift; cmd_deploy "$@" ;; + rollback) shift; cmd_rollback "$@" ;; + status) cmd_status ;; + *) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; +esac diff --git a/deploy/cd/ash-cd.timer b/deploy/cd/ash-cd.timer new file mode 100644 index 0000000..218d2f4 --- /dev/null +++ b/deploy/cd/ash-cd.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Poll main for deployable commits every 2 minutes + +[Timer] +OnBootSec=3min +OnUnitActiveSec=2min +RandomizedDelaySec=15s + +[Install] +WantedBy=timers.target diff --git a/deploy/cd/install.sh b/deploy/cd/install.sh new file mode 100755 index 0000000..d71e447 --- /dev/null +++ b/deploy/cd/install.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# 在 seed-SER9 上安装 / 更新 ash-cd(systemd 用户定时器)。可重复执行。 +# +# 脚本被复制到 ~/services/skillhub-cd/bin/ash-cd 固定下来:合并到 main 的改动不会悄悄改变部署逻辑, +# 修改 deploy/cd/ 之后要在服务器上重新执行本脚本才生效。 +# 前提:当前用户在 docker 组、gh 已登录(gh auth status)、用户 linger 已开启(loginctl enable-linger)。 +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +home_dir="$HOME/services/skillhub-cd" +unit_dir="$HOME/.config/systemd/user" + +command -v gh >/dev/null || { echo "需要 gh CLI" >&2; exit 1; } +gh auth status >/dev/null 2>&1 || { echo "gh 未登录:gh auth login" >&2; exit 1; } +docker info >/dev/null 2>&1 || { echo "当前用户无法访问 docker" >&2; exit 1; } +[ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" = yes ] \ + || echo "提示:未开启 linger,注销后定时器会停;执行 sudo loginctl enable-linger $USER" >&2 + +mkdir -p "$home_dir/bin" "$unit_dir" +install -m 0755 "$here/ash-cd.sh" "$home_dir/bin/ash-cd" +install -m 0644 "$here/ash-cd.service" "$here/ash-cd.timer" "$unit_dir/" +systemctl --user daemon-reload +systemctl --user enable --now ash-cd.timer + +echo "已安装:$home_dir/bin/ash-cd" +systemctl --user list-timers ash-cd.timer --no-pager +echo "查看日志:journalctl --user -u ash-cd -f 查看状态:$home_dir/bin/ash-cd status" diff --git a/docs/deploy.md b/docs/deploy.md index e611e86..648bd60 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,7 +1,55 @@ # AnotherSkillHub 部署手册(seed-SER9 / Docker) -单容器部署:Mac 本地构建源码 → scp 到服务器 → 服务器 docker build → compose 切镜像 tag 滚动更新。 -全程约 1–2 分钟;数据库在宿主机卷,容器重建不丢数据。 +单容器部署:服务器 docker build → compose 切镜像 tag 滚动更新。数据库在宿主机卷,容器重建不丢数据。 + +**日常上线走 CI/CD,不需要手动操作**(见下一节)。后面的「手动部署五步」是 CD 不可用时的后备,也是 CD 脚本每一步的出处。 + +## CI/CD(标准流程) + +``` +分支 / worktree ──push──▶ PR ──CI: check + docker──▶ 合并到 main ──CI 再跑一遍──▶ ash-cd 自动上线 +``` + +- **CI**:`.github/workflows/ci.yml`,GitHub Actions 托管 runner。每个 PR、main 的每次推送都跑两个检查: + - `check`:`npm ci` → lint(error 为 0)→ UI 测试 → 构建 → 服务端测试 + - `docker`:构建镜像并真正启动容器,验证 `/healthz`、首页、`/docs` 为 200,`/api/skills` 未登录为 401 +- **CD**:seed-SER9 上的 `ash-cd`(`deploy/cd/`),systemd 用户定时器每 2 分钟轮询 main。main 最新提交的 `check`、`docker` **都通过**才部署: + 备份 DB 与技能文件 → 从自己的镜像仓库 `git archive` → `docker build -t ash:` → compose 只换 tag → 冒烟 → 失败自动切回上一版本。 + 冒烟项:本机 `/healthz`、首页、`/docs` 为 200,`/api/skills` 为 401,**容器里的技能数与部署前一致**,日志无迁移/未捕获错误;公网 `/healthz` 不通只告警(隧道是独立设施)。 + 部署进度回写到仓库的 Deployments(environment: `production`);成功后只保留最近 10 个 `ash` 镜像。 +- **为什么是拉取式**:仓库公开,不在这台机器上挂自托管 runner(fork 的 PR 可能借此在服务器上执行代码)。服务器只主动拉取、只部署 CI 通过的 main 提交,不开放任何入口。 + +### 日常用法 + +```bash +git push -u origin <分支> && gh pr create --fill # CI 在 PR 上跑 +gh pr merge --squash --delete-branch # 绿了就合并;约 2 分钟 + CI 时间后自动上线 +~/services/skillhub-cd/bin/ash-cd status # 线上版本 / main 最新提交及 CI 状态 / 最近部署 +journalctl --user -u ash-cd -f # 实时日志 +``` + +### 出问题时 + +```bash +ash-cd rollback # 撤销最近一次部署(切回它之前的版本),并把被撤下的提交标记为失败 +ash-cd rollback # 切到指定版本(镜像还在时) +ash-cd deploy # 手动部署某个 CI 通过的提交;--force 跳过 CI 检查(紧急修复) +systemctl --user stop ash-cd.timer # 暂停自动部署;start 恢复 +``` + +- 部署失败或被回滚的提交记在 `~/services/skillhub-cd/state/failed-`,**不会自动重试**;修复后推新提交即可。 +- 自动回滚只切代码。新版本做过不兼容的迁移时,还要用 `backup-before-.db` / `backup-files-before-.tgz` 恢复数据(见下文「回滚」)。 +- 需要人工步骤的发布(例如「上线多用户鉴权」那样要先改 compose 环境变量、再认领数据):先 `systemctl --user stop ash-cd.timer`,按手动流程做完,再 `start`。 + +### 安装 / 更新 ash-cd(服务器上一次性) + +```bash +deploy/cd/install.sh # 复制脚本到 ~/services/skillhub-cd/bin/ash-cd,安装并启用 systemd 用户定时器 +``` + +脚本是**复制**过去固定下来的:合并到 main 的 `deploy/cd/` 改动不会自动改变部署逻辑,要在服务器上重跑 `install.sh`。 +前提:用户在 docker 组、`gh auth status` 已登录(用来读 CI 结果、写 Deployments)、`loginctl` linger 已开启。 +在预发环境演练时,可以用 `ASH_CD_SERVICES` / `ASH_CD_CONTAINER` / `ASH_CD_PROJECT` / `ASH_CD_IMAGE` / `ASH_CD_LOCAL_URL` 指向另一套容器,`ASH_CD_GH_DEPLOYMENTS=0` 不写 GitHub。 ## 环境 @@ -14,7 +62,7 @@ | 公网入口 | https://ash.709970.xyz(反向 SSH 隧道 38084→9444,公网网关 Nginx 反代,独立于本流程) | | 端口 | 容器 9444,仅绑服务器 127.0.0.1 | -## 标准部署流程(五步) +## 手动部署五步(CD 的后备) 在本地仓库(`/Users/liukun/workspace/AnotherSkillHub-dev`,分支与 main 同点)执行: