Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 103 additions & 30 deletions .github/workflows/promote-main.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,56 @@
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
cancel-in-progress: false

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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
9 changes: 6 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ develop ── 开发预览
│ develop -> main 发布 PR(仅用于审阅)
│ CI + 预览确认
│ Maintain / Admin 添加 release:promote
Promote develop to main workflow
自动发布工作流
│ fast-forward
main ── 正式文档站
Expand All @@ -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` 标签,再由有权限的维护者或管理员重新添加一次即可重试。

## 本地开发

Expand Down
Loading