diff --git a/.github/workflows/promote-main.yml b/.github/workflows/promote-main.yml index d051269..76c42da 100644 --- a/.github/workflows/promote-main.yml +++ b/.github/workflows/promote-main.yml @@ -1,16 +1,16 @@ name: Promote develop to main on: - workflow_dispatch: - inputs: - release_pr: - description: Open develop -> main release PR number - required: true - type: string + pull_request: + types: + - labeled + branches: + - main permissions: + checks: read contents: write - pull-requests: read + pull-requests: write concurrency: group: promote-main @@ -18,8 +18,39 @@ concurrency: jobs: promote: + if: github.event.label.name == 'release:promote' runs-on: ubuntu-latest steps: + - name: Authorize release promoter + id: promoter + env: + GH_TOKEN: ${{ github.token }} + PROMOTER: ${{ github.event.sender.login }} + REPOSITORY: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + + permission_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "repos/$REPOSITORY/collaborators/$PROMOTER/permission")" + role_name="$(jq -r '.role_name // "none"' <<<"$permission_json")" + + case "$role_name" in + maintain|admin) + echo "Authorized release promoter: $PROMOTER ($role_name)" + ;; + *) + echo "User $PROMOTER has repository role '$role_name'." >&2 + echo "Only maintainers or administrators may use the release:promote label to publish main." >&2 + exit 1 + ;; + esac + + echo "login=$PROMOTER" >> "$GITHUB_OUTPUT" + echo "role=$role_name" >> "$GITHUB_OUTPUT" + - name: Checkout repository uses: actions/checkout@v5 with: @@ -32,45 +63,37 @@ jobs: - name: Validate release PR and branch state id: release env: - GH_TOKEN: ${{ github.token }} - RELEASE_PR: ${{ inputs.release_pr }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_DRAFT: ${{ github.event.pull_request.draft }} + EVENT_HEAD_REF: ${{ github.event.pull_request.head.ref }} + EVENT_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} 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 + if [ "$EVENT_DRAFT" = "true" ]; then + echo "Release PR #$PR_NUMBER must be ready for review before promotion." >&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 + if [ "$EVENT_BASE_REF" != "main" ] || [ "$EVENT_HEAD_REF" != "develop" ]; then + echo "Release PR #$PR_NUMBER must be develop -> main." >&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 + if [ "$EVENT_HEAD_REPO" != "$REPOSITORY" ]; then + echo "Release PR #$PR_NUMBER 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 + if [ "$EVENT_HEAD_SHA" != "$develop_sha" ]; then + echo "Release PR head ($EVENT_HEAD_SHA) does not match current develop ($develop_sha)." >&2 echo "Refresh or recreate the release PR before promoting." >&2 exit 1 fi @@ -88,6 +111,44 @@ jobs: echo "develop_sha=$develop_sha" >> "$GITHUB_OUTPUT" echo "main_sha=$main_sha" >> "$GITHUB_OUTPUT" + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Wait for required release checks + env: + EXPECTED_DEVELOP_SHA: ${{ steps.release.outputs.develop_sha }} + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + + for attempt in $(seq 1 60); do + checks_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "repos/$REPOSITORY/commits/$EXPECTED_DEVELOP_SHA/check-runs?per_page=100")" + + validate="$(jq -r '[.check_runs[] | select(.name == "validate")] | sort_by(.started_at) | last | (.conclusion // .status // "missing")' <<<"$checks_json")" + release_policy="$(jq -r '[.check_runs[] | select(.name == "release-policy")] | sort_by(.started_at) | last | (.conclusion // .status // "missing")' <<<"$checks_json")" + + echo "validate=$validate release-policy=$release_policy" + + if [ "$validate" = "success" ] && [ "$release_policy" = "success" ]; then + exit 0 + fi + + case "$validate:$release_policy" in + *failure*|*cancelled*|*timed_out*|*action_required*|*startup_failure*|*stale*) + echo "A required release check failed or was cancelled." >&2 + exit 1 + ;; + esac + + sleep 10 + done + + echo "Timed out waiting for validate and release-policy checks." >&2 + exit 1 - name: Set up pnpm uses: pnpm/action-setup@v4 @@ -133,11 +194,23 @@ jobs: git push origin "$EXPECTED_DEVELOP_SHA:refs/heads/main" - name: Publish summary + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.release.outputs.pr_number }} + REPOSITORY: ${{ github.repository }} + shell: bash run: | + set -euo pipefail + + gh pr comment "$PR_NUMBER" --repo "$REPOSITORY" --body \ + "Promoted \`develop\` to \`main\` by fast-forward at \`${{ steps.release.outputs.develop_sha }}\` (authorized by @${{ steps.promoter.outputs.login }})." + { echo "## Promotion complete" echo - echo "- release PR: #${{ inputs.release_pr }}" + echo "- release PR: #$PR_NUMBER" + echo "- authorized promoter: @${{ steps.promoter.outputs.login }} (${{ steps.promoter.outputs.role }})" echo "- main: \`${{ steps.release.outputs.main_sha }}\` -> \`${{ steps.release.outputs.develop_sha }}\`" + echo "- trigger: \`release:promote\` label" echo "- method: fast-forward" } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 5a16b64..41ae6e0 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,9 @@ develop ── development preview │ │ develop -> main release PR (review only) │ CI + preview review + │ maintainer/admin adds release:promote ▼ -Promote develop to main workflow +automatic promotion workflow │ fast-forward ▼ main ── production documentation @@ -102,9 +103,11 @@ main ── production documentation - `main`: points to the latest released commit and publishes the production documentation; - feature branches: contain changes for a specific project, topic, or documentation batch. -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. +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. A repository maintainer or administrator applies the `release:promote` label to the release PR instead. Labels added by users with lower repository roles are rejected by the promotion workflow. -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`. +The promotion workflow waits for the required `validate` and `release-policy` checks, rebuilds the exact `develop` commit, verifies that `main` is still an ancestor of `develop`, and then fast-forwards `main`. It aborts if the release PR is a draft, the source or target branch is wrong, the label was added by someone without Maintain/Admin access, a required check fails, `develop` changes during validation, or the branch relationship is no longer safe. + +If a promotion fails, fix the reported condition, remove the `release:promote` label, and have an authorized maintainer or administrator add it again to retry. ## Local Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 254545e..3670657 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -89,8 +89,9 @@ develop ── 开发预览 │ │ develop -> main 发布 PR(仅用于审阅) │ CI + 预览确认 + │ Maintain / Admin 添加 release:promote ▼ -Promote develop to main workflow +自动发布工作流 │ fast-forward ▼ main ── 正式文档站 @@ -100,9 +101,11 @@ main ── 正式文档站 * `main`:指向最近一次正式发布的提交并发布正式文档; * 功能分支:用于编写单个项目、主题或批次的文档变更。 -普通文档变更应通过 Pull Request 合入 `develop`。确认开发预览后,创建 `develop -> main` 发布 PR,用于查看正式发布的完整差异。该发布 PR **不要使用 GitHub 的 Merge / Rebase / Squash 按钮合并**,而是在 **Actions → Promote develop to main** 中输入发布 PR 编号,由工作流完成校验并将 `main` fast-forward 到已经审阅的 `develop` 提交。 +普通文档变更应通过 Pull Request 合入 `develop`。确认开发预览后,创建 `develop -> main` 发布 PR,用于查看正式发布的完整差异。该发布 PR **不要使用 GitHub 的 Merge / Rebase / Squash 按钮合并**,而是由具备仓库 **Maintain 或 Admin** 权限的维护者给发布 PR 添加 `release:promote` 标签。较低权限用户即使能够添加该标签,发布工作流也会拒绝执行发布。 -如果发布 PR 不是 `develop -> main`、校验期间 `develop` 发生变化,或者 `main` 已不再是 `develop` 的祖先,发布工作流会直接终止。 +发布工作流会校验实际添加标签的用户权限,等待 `validate` 和 `release-policy` 两个必要检查通过,重新构建该 PR 对应的准确 `develop` 提交,确认 `main` 仍然是 `develop` 的祖先,然后将 `main` fast-forward 到该提交。如果发布 PR 仍是 Draft、来源或目标分支错误、添加标签的人没有 Maintain/Admin 权限、必要检查失败、校验期间 `develop` 发生变化,或者分支关系已经不安全,发布会直接终止。 + +如果发布失败,修复提示的问题后移除 `release:promote` 标签,再由有权限的维护者或管理员重新添加一次即可重试。 ## 本地开发