diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d7aeadd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: node-study CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: node-study-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-site: + name: 生成静态站点并校验 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: 安装 Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: 安装构建依赖 + # 用 ci 保证与 lock 文件一致(cache: npm 依赖 package-lock.json 存在) + run: npm ci --no-audit --no-fund + + - name: 生成静态站点 + run: npm run build + + - name: 校验产物 + run: | + set -eo pipefail + pages=$(find dist -name '*.html' | wc -l | tr -d ' ') + echo "HTML 页面数: $pages" + [ "$pages" -gt 300 ] || { echo "❌ 页面数异常(预期 >300)"; exit 1; } + [ -f dist/index.html ] || { echo "❌ 缺首页"; exit 1; } + [ -f dist/assets/style.css ] || { echo "❌ 缺样式"; exit 1; } + [ -f dist/chapter/01/index.html ] || { echo "❌ 缺章节页"; exit 1; } + + # 章节数量应与 chapters 目录一致 + chapters=$(ls -d chapters/*/ 2>/dev/null | wc -l | tr -d ' ') + built=$(ls -d dist/chapter/*/ 2>/dev/null | wc -l | tr -d ' ') + echo "源章节数: $chapters 已构建: $built" + [ "$chapters" = "$built" ] || { echo "❌ 章节数不匹配"; exit 1; } + echo "✅ 产物校验通过" + + - name: 上传站点产物 + uses: actions/upload-artifact@v4 + with: + name: node-study-site + path: dist/ + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..eb5f846 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,101 @@ +name: node-study Deploy + +# 部署通道:阿里云 Cloud Assistant(aliyun ecs RunCommand),不用 SSH。 +# 原因同 java-study / python-learning:GitHub Actions runner 属境外出口, +# 直连 47.93.15.207:22 会被阿里云上游静默丢包(安全组规则在该层之下,加了也无效)。 + +on: + push: + branches: [main] + paths-ignore: + - '**/*.md' + workflow_dispatch: + +concurrency: + group: node-study-deploy + cancel-in-progress: false + +jobs: + deploy: + name: 部署到 ECS + runs-on: ubuntu-latest + environment: production + + steps: + - uses: actions/checkout@v4 + + - name: 记录版本 + id: before + run: | + echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "message=$(git log -1 --pretty='%s')" >> "$GITHUB_OUTPUT" + + - name: 通过 Cloud Assistant 下发部署脚本 + env: + ALICLOUD_ACCESS_KEY_ID: ${{ secrets.ALIYUN_AK }} + ALICLOUD_ACCESS_KEY_SECRET: ${{ secrets.ALIYUN_SK }} + REGION: cn-beijing + INSTANCE_ID: i-2zecv4kgasiw24p97v0t + run: | + set -eo pipefail + curl -fsSL -o aliyun-cli.tgz https://aliyuncli.alicdn.com/aliyun-cli-linux-latest-amd64.tgz + tar -xzf aliyun-cli.tgz + install -m 0755 aliyun /usr/local/bin/aliyun + + aliyun configure set --mode AK --profile ecs-deploy \ + --region "$REGION" \ + --access-key-id "$ALICLOUD_ACCESS_KEY_ID" \ + --access-key-secret "$ALICLOUD_ACCESS_KEY_SECRET" >/dev/null + + IID=$(aliyun ecs RunCommand --profile ecs-deploy --region "$REGION" \ + --Type RunShellScript --Timeout 900 \ + --InstanceId.1 "$INSTANCE_ID" \ + --ContentEncoding Base64 \ + --CommandContent "$(base64 -w0 < deploy/deploy-node.sh)" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['InvokeId'])") + echo "InvokeId=$IID" + echo "::add-mask::$IID" + + while :; do + STATUS=$(aliyun ecs DescribeInvocationResults --profile ecs-deploy \ + --region "$REGION" --InvokeId "$IID" | python3 deploy/ca_result.py status) + echo " 状态: $STATUS" + case "$STATUS" in Running|Pending) sleep 10 ;; *) break ;; esac + done + + # 用仓库内脚本解析,避免内联多行 Python 顶断 YAML 缩进 + aliyun ecs DescribeInvocationResults --profile ecs-deploy \ + --region "$REGION" --InvokeId "$IID" | python3 deploy/ca_result.py output \ + | tee /tmp/ca-output.log + + grep -q "NODE-STUDY DEPLOY DONE" /tmp/ca-output.log || { + echo "::error::部署未完成"; exit 1; } + + - name: 部署结果 + if: always() + run: | + { + echo "### 部署完成" + echo "" + echo "- commit: \`${{ steps.before.outputs.commit }}\` — ${{ steps.before.outputs.message }}" + echo "- 站点: http://47.93.15.207:8087/" + echo "" + echo '```' + tail -25 /tmp/ca-output.log 2>/dev/null || echo '(无输出)' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: 外网复验 + run: | + set -eo pipefail + sleep 3 + echo -n "首页 : " + curl -sS --max-time 15 -o /dev/null -w '%{http_code}\n' http://47.93.15.207:8087/ + echo -n "章节01 : " + curl -sS --max-time 15 -o /dev/null -w '%{http_code}\n' http://47.93.15.207:8087/chapter/01/index.html + echo -n "样式 : " + curl -sS --max-time 15 -o /dev/null -w '%{http_code}\n' http://47.93.15.207:8087/assets/style.css + # 首页应含 60 个章节链接 + n=$(curl -sS --max-time 15 http://47.93.15.207:8087/ | grep -o 'href="/chapter/[0-9]*/index.html"' | sort -u | wc -l | tr -d ' ') + echo "章节链接数: $n" + [ "$n" -ge 55 ] || { echo "❌ 章节链接异常"; exit 1; } \ No newline at end of file diff --git a/deploy/ca_result.py b/deploy/ca_result.py new file mode 100644 index 0000000..f61df20 --- /dev/null +++ b/deploy/ca_result.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""解析 `aliyun ecs DescribeInvocationResults` 的 JSON 输出。 + +用法: + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py status + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py output + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py both + +单独放成文件而不是内联在 workflow 的 run: | 块里,是因为内联多行 Python +必须顶格写,会顶断 YAML 的块标量缩进。 +""" +from __future__ import annotations + +import base64 +import json +import sys + + +def first_result(payload: dict) -> dict: + invocation = payload["Invocation"]["InvocationResults"] + results = invocation["InvocationResult"] + if not results: + raise SystemExit("DescribeInvocationResults 返回空结果") + return results[0] + + +def main() -> int: + if len(sys.argv) < 2 or sys.argv[1] not in {"status", "output", "both"}: + print(__doc__, file=sys.stderr) + return 2 + + mode = sys.argv[1] + result = first_result(json.load(sys.stdin)) + + if mode in {"status", "both"}: + print(result.get("InvocationStatus", "")) + + if mode in {"output", "both"}: + raw = result.get("Output", "") or "" + sys.stdout.write(base64.b64decode(raw).decode(errors="replace")) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/deploy/deploy-node.sh b/deploy/deploy-node.sh new file mode 100644 index 0000000..75ab204 --- /dev/null +++ b/deploy/deploy-node.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# node-study 静态站点部署脚本(在 ECS 上执行) +# +# node-study 本就无后端无登录,这里只做:拉源码 → 构建静态站 → 确保 vhost 存在 → reload nginx +set -eo pipefail + +SITE_DIR='/www/wwwroot/node-study' +VHOST='/www/server/panel/vhost/nginx/node-study.conf' +PORT=8087 + +echo "=== 1. 下载源码 ===" +TAR=/tmp/_node-study.tar.gz +rm -f "$TAR" +http=$(curl -sSL -o "$TAR" -w '%{http_code}' --max-time 180 \ + "https://codeload.github.com/webxiongda/node-study/tar.gz/refs/heads/main") +[ "$http" = "200" ] || { echo "❌ 下载失败 HTTP=$http"; exit 1; } +tar -tzf "$TAR" >/dev/null && echo "tar OK ($(du -h $TAR | cut -f1))" + +echo "=== 2. 替换源码(dist 会重建,node_modules 可留)===" +cd "$SITE_DIR" 2>/dev/null || { mkdir -p "$SITE_DIR"; cd "$SITE_DIR"; } +KEEP=/tmp/_node_keep +rm -rf "$KEEP"; mkdir -p "$KEEP" +[ -d node_modules ] && mv node_modules "$KEEP/" || true +rm -rf chapters nodejs-learning 60-days-nodejs dist +tar -xzf "$TAR" --strip-components=1 +rm -f "$TAR" +[ -d "$KEEP/node_modules" ] && mv "$KEEP/node_modules" ./ || true +rm -rf "$KEEP" + +echo "=== 3. 安装构建依赖并生成站点 ===" +export PATH=/usr/local/bin:$PATH +npm ci --no-audit --no-fund 2>&1 | tail -2 +npm run build 2>&1 | tail -4 +echo "HTML 页面数: $(find dist -name '*.html' | wc -l) dist 大小: $(du -sh dist | cut -f1)" + +echo "=== 4. 确保 nginx vhost 存在 ====" +if ! grep -q "listen ${PORT};" "$VHOST" 2>/dev/null; then + echo "创建 vhost -> ${VHOST}" + cat > "$VHOST" << NGINX_EOF +server { + listen ${PORT}; + server_name _; + + root ${SITE_DIR}/dist; + index index.html; + + access_log /www/wwwlogs/node-study.access.log; + error_log /www/wwwlogs/node-study.error.log; + + location / { + try_files \$uri \$uri/ /index.html; + } + + location /raw/ { + default_type text/plain; + charset utf-8; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|webp|woff2?)\$ { + expires 7d; + add_header Cache-Control "public, max-age=604800"; + try_files \$uri =404; + } +} +NGINX_EOF +else + echo "vhost 已存在,跳过创建" +fi + +echo "=== 5. 防火墙放行 ${PORT}(仅首次需要,幂等)===" +if ! firewall-cmd --list-ports 2>/dev/null | tr ' ' '\n' | grep -qx "${PORT}/tcp"; then + firewall-cmd --add-port=${PORT}/tcp --permanent >/dev/null 2>&1 && firewall-cmd --reload >/dev/null 2>&1 && echo "已放行 ${PORT}" +else + echo "${PORT} 已放行" +fi + +echo "=== 6. 校验并重载 nginx ===" +nginx -t && nginx -s reload && echo "NGINX RELOAD OK" + +echo "=== 7. 本机自测 ===" +sleep 2 +for p in "/" "/chapter/01/index.html" "/assets/style.css"; do + printf " %-28s %s\n" "$p" "$(curl -s -m 8 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}$p")" +done +echo "=== NODE-STUDY DEPLOY DONE ===" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cfda8eb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "node-study-site", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-study-site", + "version": "1.0.0", + "devDependencies": { + "highlight.js": "^11.11.1", + "marked": "^14.1.4" + } + }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/marked": { + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", + "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cadf335 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "node-study-site", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Node.js 全栈学习路线 · 静态站点生成器(markdown → HTML)", + "scripts": { + "build": "node build-site.mjs" + }, + "devDependencies": { + "highlight.js": "^11.11.1", + "marked": "^14.1.4" + } +} \ No newline at end of file