diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26de18d..e6470bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,26 @@ permissions: contents: read jobs: + release-policy: + if: github.event_name == 'pull_request' && github.base_ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Require develop as the release source + env: + HEAD_REF: ${{ github.head_ref }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + run: | + if [ "$HEAD_REF" != "develop" ]; then + echo "Pull requests targeting main must come from develop, got: $HEAD_REF" >&2 + exit 1 + fi + + if [ "$HEAD_REPOSITORY" != "$REPOSITORY" ]; then + echo "The develop -> main release PR must come from this repository." >&2 + exit 1 + fi + validate: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/promote-main.yml b/.github/workflows/promote-main.yml new file mode 100644 index 0000000..d051269 --- /dev/null +++ b/.github/workflows/promote-main.yml @@ -0,0 +1,143 @@ +name: Promote develop to main + +on: + workflow_dispatch: + inputs: + release_pr: + description: Open develop -> main release PR number + required: true + type: string + +permissions: + contents: write + pull-requests: read + +concurrency: + group: promote-main + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + + - name: Fetch release branches + run: git fetch origin main develop --no-tags + + - name: Validate release PR and branch state + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PR: ${{ inputs.release_pr }} + REPOSITORY: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + + if ! [[ "$RELEASE_PR" =~ ^[0-9]+$ ]]; then + echo "release_pr must be a pull request number." >&2 + exit 1 + fi + + pr_json="$(gh api "repos/$REPOSITORY/pulls/$RELEASE_PR")" + state="$(jq -r '.state' <<<"$pr_json")" + base_ref="$(jq -r '.base.ref' <<<"$pr_json")" + head_ref="$(jq -r '.head.ref' <<<"$pr_json")" + head_repo="$(jq -r '.head.repo.full_name' <<<"$pr_json")" + head_sha="$(jq -r '.head.sha' <<<"$pr_json")" + + if [ "$state" != "open" ]; then + echo "Release PR #$RELEASE_PR must be open." >&2 + exit 1 + fi + + if [ "$base_ref" != "main" ] || [ "$head_ref" != "develop" ]; then + echo "Release PR #$RELEASE_PR must be develop -> main." >&2 + exit 1 + fi + + if [ "$head_repo" != "$REPOSITORY" ]; then + echo "Release PR #$RELEASE_PR must use this repository's develop branch." >&2 + exit 1 + fi + + develop_sha="$(git rev-parse origin/develop)" + main_sha="$(git rev-parse origin/main)" + + if [ "$head_sha" != "$develop_sha" ]; then + echo "Release PR head ($head_sha) does not match current develop ($develop_sha)." >&2 + echo "Refresh or recreate the release PR before promoting." >&2 + exit 1 + fi + + if ! git merge-base --is-ancestor "$main_sha" "$develop_sha"; then + echo "main is not an ancestor of develop; fast-forward promotion is unsafe." >&2 + echo "Reconcile develop with main before retrying." >&2 + exit 1 + fi + + if [ "$main_sha" = "$develop_sha" ]; then + echo "main already points to the current develop commit." >&2 + exit 1 + fi + + echo "develop_sha=$develop_sha" >> "$GITHUB_OUTPUT" + echo "main_sha=$main_sha" >> "$GITHUB_OUTPUT" + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.9.0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Checkout release candidate + run: git checkout --detach "${{ steps.release.outputs.develop_sha }}" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate release candidate + run: pnpm check:build + + - name: Recheck develop and fast-forward main + env: + EXPECTED_DEVELOP_SHA: ${{ steps.release.outputs.develop_sha }} + shell: bash + run: | + set -euo pipefail + + git fetch origin main develop --no-tags + current_develop_sha="$(git rev-parse origin/develop)" + current_main_sha="$(git rev-parse origin/main)" + + if [ "$current_develop_sha" != "$EXPECTED_DEVELOP_SHA" ]; then + echo "develop changed while the release was being validated; aborting." >&2 + exit 1 + fi + + if ! git merge-base --is-ancestor "$current_main_sha" "$EXPECTED_DEVELOP_SHA"; then + echo "main changed and is no longer an ancestor of the validated develop commit; aborting." >&2 + exit 1 + fi + + git push origin "$EXPECTED_DEVELOP_SHA:refs/heads/main" + + - name: Publish summary + run: | + { + echo "## Promotion complete" + echo + echo "- release PR: #${{ inputs.release_pr }}" + echo "- main: \`${{ steps.release.outputs.main_sha }}\` -> \`${{ steps.release.outputs.develop_sha }}\`" + echo "- method: fast-forward" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 8d147b5..5a16b64 100644 --- a/README.md +++ b/README.md @@ -81,25 +81,30 @@ For application behavior, runtime errors, feature requests, or security issues, ## Branches and Publishing -This repository uses the following publishing flow: +`main` is the stable/default branch and `develop` is the integration and preview branch. The repository keeps a linear history: `main` is always equal to, or an ancestor of, `develop`. ```text feature branch - ↓ -develop - ↓ -GitHub Pages development preview - ↓ -main - ↓ -production documentation site + │ Pull Request (rebase or squash) + ▼ +develop ── development preview + │ + │ develop -> main release PR (review only) + │ CI + preview review + ▼ +Promote develop to main workflow + │ fast-forward + ▼ +main ── production documentation ``` -- `develop`: receives documentation changes and publishes the development preview; -- `main`: contains reviewed documentation ready for production; +- `develop`: receives reviewed documentation changes and publishes the development preview; +- `main`: points to the latest released commit and publishes the production documentation; - feature branches: contain changes for a specific project, topic, or documentation batch. -Documentation changes should normally merge into `develop` first. After reviewing the preview, they can be promoted to `main`. +Normal changes should go through a Pull Request into `develop`. After the preview is accepted, open a `develop -> main` release PR to review the exact production diff. Do not merge that release PR with GitHub's merge buttons. Instead, run **Actions → Promote develop to main**, enter the release PR number, and let the workflow validate and fast-forward `main` to the reviewed `develop` commit. + +The promotion workflow aborts if the release PR does not target `main` from `develop`, if `develop` changes during validation, or if `main` is no longer an ancestor of `develop`. ## Local Development diff --git a/README.zh-CN.md b/README.zh-CN.md index fe01453..254545e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -79,25 +79,30 @@ docs/.vitepress/navigation.ts ## 分支与发布 -本仓库采用以下分支流程: +`main` 是稳定/默认分支,`develop` 是集成与预览分支。仓库保持线性历史:`main` 始终与 `develop` 相同,或是 `develop` 的祖先提交。 ```text 功能分支 - ↓ -develop - ↓ -GitHub Pages 开发预览 - ↓ -main - ↓ -正式文档站 + │ Pull Request(rebase 或 squash) + ▼ +develop ── 开发预览 + │ + │ develop -> main 发布 PR(仅用于审阅) + │ CI + 预览确认 + ▼ +Promote develop to main workflow + │ fast-forward + ▼ +main ── 正式文档站 ``` -* `develop`:接收文档变更并生成开发预览; -* `main`:保存已经确认并准备正式发布的文档; +* `develop`:接收已经审阅的文档变更并生成开发预览; +* `main`:指向最近一次正式发布的提交并发布正式文档; * 功能分支:用于编写单个项目、主题或批次的文档变更。 -文档变更应优先合并到 `develop`,确认预览效果后再同步到 `main`。 +普通文档变更应通过 Pull Request 合入 `develop`。确认开发预览后,创建 `develop -> main` 发布 PR,用于查看正式发布的完整差异。该发布 PR **不要使用 GitHub 的 Merge / Rebase / Squash 按钮合并**,而是在 **Actions → Promote develop to main** 中输入发布 PR 编号,由工作流完成校验并将 `main` fast-forward 到已经审阅的 `develop` 提交。 + +如果发布 PR 不是 `develop -> main`、校验期间 `develop` 发生变化,或者 `main` 已不再是 `develop` 的祖先,发布工作流会直接终止。 ## 本地开发