diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml new file mode 100644 index 00000000..216db370 --- /dev/null +++ b/.github/workflows/preview-build.yml @@ -0,0 +1,60 @@ +# PR 预览第一阶段:在 PR 上下文中构建文档站点 +# +# 该工作流会执行 PR 中的代码(包括来自 fork 的 PR),因此: +# - 只授予只读权限,不接触任何 secrets +# - 只负责构建并上传产物,部署交给 preview-deploy.yml(workflow_run 触发,在主仓库上下文中运行) +name: Preview Build +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "course/**" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/preview-*.yml" +permissions: + contents: read +concurrency: + group: preview-build-${{ github.event.pull_request.number }} + cancel-in-progress: true +jobs: + preview-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 # lastUpdated 依赖完整的 git 历史 + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build with VitePress + run: pnpm run build + - name: Save PR metadata + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + mkdir -p preview-meta + printf '%s' "$PR_NUMBER" > preview-meta/pr-number + printf '%s' "$HEAD_SHA" > preview-meta/head-sha + - name: Upload site + uses: actions/upload-artifact@v7 + with: + name: preview-site + path: course/.vitepress/dist + include-hidden-files: true + retention-days: 3 + if-no-files-found: error + - name: Upload metadata + uses: actions/upload-artifact@v7 + with: + name: preview-meta + path: preview-meta + retention-days: 3 + if-no-files-found: error diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 00000000..acccf81e --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,171 @@ +# PR 预览第二阶段:把 Preview Build 产出的静态站点部署到 Cloudflare Pages,并在 PR 中回帖 +# +# workflow_run 始终使用默认分支上的工作流定义,在主仓库上下文中运行,可以读取 secrets, +# 因此即使是来自 fork 的 PR 也能获得预览。这里绝不检出或执行 PR 中的代码: +# - 构建产物只作为静态文件上传 +# - 元数据(PR 编号、提交)先做格式校验,再通过 GitHub API 交叉验证 +# +# 需要的配置(仓库 Settings → Secrets and variables → Actions): +# - secrets.CLOUDFLARE_API_TOKEN:权限为 Account → Cloudflare Pages → Edit +# - secrets.CLOUDFLARE_ACCOUNT_ID +# - vars.CLOUDFLARE_PAGES_PROJECT(可选,默认 zig-course) +# 未配置 secrets 时只输出提示并跳过,不会让 CI 失败。 +name: Preview Deploy +on: + workflow_run: + workflows: ["Preview Build"] + types: [completed] +permissions: + actions: read # 下载其他运行的产物 + pull-requests: write # 在 PR 中创建 / 更新预览评论 + statuses: write # 在 PR 提交上显示 Preview 状态与链接 +concurrency: + group: preview-deploy-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true +jobs: + preview-deploy: + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'zig-course' }} + steps: + - name: Download metadata + uses: actions/download-artifact@v8 + with: + name: preview-meta + path: preview-meta + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Validate metadata + id: meta + env: + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} + RUN_HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} + RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + pr="$(head -c 32 preview-meta/pr-number)" + sha="$(head -c 64 preview-meta/head-sha)" + if ! [[ "$pr" =~ ^[0-9]+$ ]] || ! [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::预览元数据格式不正确" + exit 1 + fi + if [ "$sha" != "$RUN_HEAD_SHA" ]; then + echo "::error::元数据中的提交与触发本次运行的提交不一致" + exit 1 + fi + if [ -z "$RUN_HEAD_REPO" ] || [ -z "$RUN_HEAD_OWNER" ] || [ -z "$RUN_HEAD_BRANCH" ]; then + echo "::error::无法确定触发本次运行的 head 仓库与分支" + exit 1 + fi + # 元数据由执行过 PR 代码的构建环境写出,其中的 PR 编号不能单独信任。 + # 这里用 workflow_run 事件自带的 head 仓库、分支与提交(由 GitHub 提供)反查打开的 PR, + # 元数据中的编号必须在结果中。不使用 workflow_run.pull_requests:来自 fork 的 PR 该字段为空。 + matched="$(gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" \ + -f state=open -f head="$RUN_HEAD_OWNER:$RUN_HEAD_BRANCH" -f per_page=100 \ + --jq '.[] | select(.head.sha == env.RUN_HEAD_SHA and .head.repo.full_name == env.RUN_HEAD_REPO) | .number')" + if [ -z "$matched" ]; then + echo "::notice::触发本次运行的 PR 已关闭或已有更新的提交,跳过本次过期的预览部署" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! grep -qx "$pr" <<< "$matched"; then + echo "::error::元数据中的 PR 编号与触发本次运行的 PR 不一致" + exit 1 + fi + { + echo "skip=false" + echo "pr=$pr" + echo "sha=$sha" + } >> "$GITHUB_OUTPUT" + + - name: Check Cloudflare credentials + id: cf + if: steps.meta.outputs.skip == 'false' + env: + CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + if [ -n "$CF_TOKEN" ] && [ -n "$CF_ACCOUNT" ]; then + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::未配置 CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID,跳过预览部署" + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + - name: Download site + if: steps.cf.outputs.ok == 'true' + uses: actions/download-artifact@v8 + with: + name: preview-site + path: dist + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Deploy to Cloudflare Pages + id: deploy + if: steps.cf.outputs.ok == 'true' + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: "4" + command: pages deploy dist --project-name=${{ env.PAGES_PROJECT }} --branch=pr-${{ steps.meta.outputs.pr }} --commit-hash=${{ steps.meta.outputs.sha }} --commit-dirty=true + + - name: Comment on PR + if: steps.cf.outputs.ok == 'true' + env: + PR: ${{ steps.meta.outputs.pr }} + SHA: ${{ steps.meta.outputs.sha }} + ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }} + DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + preview_url="${ALIAS_URL:-$DEPLOY_URL}" + if [ -z "$preview_url" ]; then + echo "::error::未能从 wrangler 输出中获取预览地址" + exit 1 + fi + marker='' + { + echo "$marker" + echo "### 文档预览已部署" + echo + echo "| 项目 | 链接 |" + echo "| --- | --- |" + echo "| 预览地址(随 PR 更新) | $preview_url |" + echo "| 本次部署(固定版本) | ${DEPLOY_URL:-$preview_url} |" + echo "| 对应提交 | $SHA |" + echo + echo "由 [Preview Deploy]($RUN_URL) 自动生成,PR 每次更新后会刷新这条评论。" + } > "$RUNNER_TEMP/preview-comment.md" + ids="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | startswith(""))) | .id')" + cid="${ids%%$'\n'*}" + if [ -n "$cid" ]; then + gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$cid" -F body=@"$RUNNER_TEMP/preview-comment.md" > /dev/null + else + gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/preview-comment.md" > /dev/null + fi + gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" \ + -f state=success -f context=Preview \ + -f description="文档预览已部署" -f target_url="$preview_url" > /dev/null + { + echo "### 文档预览" + echo + echo "- PR:#$PR" + echo "- 预览地址:$preview_url" + echo "- 本次部署:${DEPLOY_URL:-$preview_url}" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Report failure + if: failure() && steps.meta.outputs.sha != '' + env: + SHA: ${{ steps.meta.outputs.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" \ + -f state=failure -f context=Preview \ + -f description="文档预览部署失败" -f target_url="$RUN_URL" > /dev/null