ci(release): 完善双分支自动拆分与发布流程

为 ThinkAdmin 主仓库补强 v6-dev 与 v8-dev 的自动化处理机制,避免两个开发分支在插件仓库分支、发布 Tag 与 Release Notes 生成时互相覆盖。

主要内容:

- 拆分工作流仅允许 v6-dev 与 v8-dev,按同名分支推送各插件仓库。

- 发布工作流按 v6.* / v8.* Tag 自动选择对应开发分支,并要求 Tag 指向对应分支最新提交。

- 发布脚本同步推送插件分支和同名 Tag,遇到冲突 Tag 会拒绝覆盖。

- 新增中文 Release Notes 生成器,按提交前缀自动汇总本次变更内容。

- 静态分析步骤兼容 v6-dev 未定义 analyse 脚本的情况,避免旧分支发布失败。
This commit is contained in:
Anyon 2026-05-08 15:54:58 +08:00
parent 4a824901fc
commit 1ddee14de6
5 changed files with 296 additions and 78 deletions

118
.github/generate-release-notes.py vendored Executable file
View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import re
import subprocess
from collections import OrderedDict
from pathlib import Path
CURRENT_TAG = os.environ["CURRENT_TAG"]
PREVIOUS_TAG = os.environ.get("PREVIOUS_TAG", "")
RELEASE_BRANCH = os.environ.get("RELEASE_BRANCH", "")
GITHUB_REPOSITORY = os.environ.get("GITHUB_REPOSITORY", "zoujingli/ThinkAdmin")
GROUPS = OrderedDict([
("feat", "新增功能"),
("fix", "问题修复"),
("refactor", "重构调整"),
("perf", "性能优化"),
("pref", "性能优化"),
("style", "样式调整"),
("docs", "文档更新"),
("test", "测试质量"),
("build", "构建发布"),
("ci", "持续集成"),
("chore", "工程维护"),
("other", "其他变更"),
])
PREFIX_RE = re.compile(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?[:]\s*(?P<title>.+)$")
def git_lines(*args: str) -> list[str]:
out = subprocess.check_output(["git", *args], text=True)
return [line for line in out.splitlines() if line]
def release_range() -> str:
if PREVIOUS_TAG:
return f"{PREVIOUS_TAG}..{CURRENT_TAG}"
return CURRENT_TAG
def commits() -> list[tuple[str, str]]:
lines = git_lines("log", "--pretty=format:%H%x01%s", release_range())
result: list[tuple[str, str]] = []
for line in lines:
sha, subject = line.split("\x01", 1)
result.append((sha, subject))
return result
def normalize(subject: str) -> tuple[str, str]:
match = PREFIX_RE.match(subject)
if not match:
return "other", subject
typ = match.group("type")
title = match.group("title")
if typ not in GROUPS:
typ = "other"
scope = match.group("scope")
if scope:
title = f"{scope}{title}"
return typ, title
def main() -> None:
grouped: dict[str, list[str]] = {key: [] for key in GROUPS}
all_commits = commits()
for sha, subject in all_commits:
typ, title = normalize(subject)
grouped[typ].append(f"- {title} ({sha[:8]})")
lines: list[str] = []
lines.append(f"## Release {CURRENT_TAG}")
lines.append("")
if RELEASE_BRANCH:
lines.append(f"- 发布分支:`{RELEASE_BRANCH}`")
if PREVIOUS_TAG:
compare_url = f"https://github.com/{GITHUB_REPOSITORY}/compare/{PREVIOUS_TAG}...{CURRENT_TAG}"
lines.append(f"- 对比范围:[`{PREVIOUS_TAG}...{CURRENT_TAG}`]({compare_url})")
else:
lines.append("- 对比范围:首次发布标签")
lines.append(f"- 提交数量:{len(all_commits)}")
lines.append("")
lines.append("## 本次变更摘要")
lines.append("")
summary = [f"{label} {len(items)}" for key, label in GROUPS.items() if (items := grouped[key])]
lines.append("".join(summary) if summary else "- 本次发布没有检测到提交变更。")
lines.append("")
lines.append("## 变更明细")
lines.append("")
for key, label in GROUPS.items():
items = grouped[key]
if not items:
continue
lines.append(f"### {label}")
lines.extend(items)
lines.append("")
lines.append("## 发布说明")
lines.append("")
lines.append("- 主仓库 Release 由 GitHub Actions 自动创建。")
lines.append("- 插件仓库会同步推送同名分支和同名 TagPackagist 可通过 GitHub Hook 自动刷新。")
lines.append("- v6 与 v8 使用不同主版本号 Tag避免两个开发分支发布互相覆盖。")
lines.append("")
out = Path("log") / f"{CURRENT_TAG}.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines), encoding="utf-8")
print(out)
if __name__ == "__main__":
main()

101
.github/release.sh vendored Normal file → Executable file
View File

@ -1,47 +1,78 @@
#!/usr/bin/env bash
set -e
if (( "$#" == 0 ))
then
echo "Tag has to be provided"
exit 1
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <tag> [release-branch] [repo ...]" >&2
exit 1
fi
NOW=$(date +%s)
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
VERSION=$1
BASEPATH=$(cd `dirname $0`; cd ../plugin/; pwd)
VERSION="$1"
shift
if [ -z $2 ] ; then
repos=$(ls $BASEPATH)
if [[ $# -gt 0 && "$1" =~ ^v[0-9]+-dev$ ]]; then
RELEASE_BRANCH="$1"
shift
else
repos=${@:2}
RELEASE_BRANCH="${RELEASE_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"
fi
for REMOTE in $repos
do
echo ""
echo ""
echo "Cloning $REMOTE";
TMP_DIR="/tmp/ThinkAdminSplit"
REMOTE_URL="git@github.com:zoujingli/$REMOTE.git"
case "$VERSION" in
v6.*) EXPECTED_BRANCH="v6-dev" ;;
v8.*) EXPECTED_BRANCH="v8-dev" ;;
*)
echo "Only v6.* and v8.* tags are allowed, current: ${VERSION}" >&2
exit 1
;;
esac
rm -rf $TMP_DIR;
mkdir $TMP_DIR;
if [[ "$RELEASE_BRANCH" != "$EXPECTED_BRANCH" ]]; then
echo "Tag ${VERSION} must be released from ${EXPECTED_BRANCH}, current: ${RELEASE_BRANCH}" >&2
exit 1
fi
(
cd $TMP_DIR;
BASEPATH="$(cd "$(dirname "$0")"; cd ../plugin/; pwd)"
REPOS=("$@")
git clone $REMOTE_URL .
git checkout "$CURRENT_BRANCH";
remote() {
local name="$1"
local url="$2"
if git remote get-url "$name" >/dev/null 2>&1; then
git remote set-url "$name" "$url"
else
git remote add "$name" "$url"
fi
}
if [[ $(git log --pretty="%d" -n 1 | grep tag --count) -eq 0 ]]; then
echo "Releasing $REMOTE"
git tag $VERSION
git push origin --tags
fi
)
push_split_release() {
local repo="$1"
local prefix="plugin/${repo}"
local sha1 remote_tag
sha1="$(.github/splitsh-lite-linux --prefix="$prefix")"
echo "Release ${prefix} => ${sha1}, branch ${RELEASE_BRANCH}, tag ${VERSION}"
git push "$repo" "${sha1}:refs/heads/${RELEASE_BRANCH}" -f
remote_tag="$(git ls-remote --tags "$repo" "refs/tags/${VERSION}" | awk '{print $1}' || true)"
if [[ -n "$remote_tag" ]]; then
if [[ "$remote_tag" == "$sha1" ]]; then
echo "Tag ${VERSION} already exists on ${repo} at ${sha1}, skip."
return
fi
echo "Tag ${VERSION} already exists on ${repo} at ${remote_tag}, expected ${sha1}. Refuse to overwrite." >&2
exit 1
fi
git push "$repo" "${sha1}:refs/tags/${VERSION}"
}
if [[ ${#REPOS[@]} -eq 0 ]]; then
while IFS= read -r repo; do
REPOS+=("$repo")
done < <(find "$BASEPATH" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort)
fi
for repo in "${REPOS[@]}"; do
remote "$repo" "git@github.com:zoujingli/${repo}.git"
push_split_release "$repo"
done
TIME=$(echo "$(date +%s) - $NOW" | bc)
printf "Execution time: %f seconds" $TIME

View File

@ -1,27 +1,51 @@
#!/usr/bin/env bash
set -e
set -x
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
BASEPATH=$(cd `dirname $0`; cd ../plugin/; pwd)
REPOS=$@
function split()
{
SHA1=`.github/splitsh-lite-linux --prefix=$1`
git push $2 "$SHA1:refs/heads/$CURRENT_BRANCH" -f
set -euo pipefail
CURRENT_BRANCH="${SPLIT_BRANCH:-${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}}"
BASEPATH="$(cd "$(dirname "$0")"; cd ../plugin/; pwd)"
REPOS=("$@")
case "$CURRENT_BRANCH" in
v6-dev|v8-dev) ;;
*)
echo "Only v6-dev and v8-dev are allowed to split, current: ${CURRENT_BRANCH}" >&2
exit 1
;;
esac
remote() {
local name="$1"
local url="$2"
if git remote get-url "$name" >/dev/null 2>&1; then
git remote set-url "$name" "$url"
else
git remote add "$name" "$url"
fi
}
function remote()
{
git remote add $1 $2 || true
split_branch() {
local repo="$1"
local prefix="plugin/${repo}"
local sha1
sha1="$(.github/splitsh-lite-linux --prefix="$prefix")"
echo "Split ${prefix} => ${sha1}, push branch ${CURRENT_BRANCH}"
git push "$repo" "${sha1}:refs/heads/${CURRENT_BRANCH}" -f
}
git pull origin $CURRENT_BRANCH
if [[ $# -eq 0 ]]; then
REPOS=$(ls $BASEPATH)
if [[ ${#REPOS[@]} -eq 0 ]]; then
while IFS= read -r repo; do
REPOS+=("$repo")
done < <(find "$BASEPATH" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort)
fi
for REPO in $REPOS ; do
remote $REPO git@github.com:zoujingli/$REPO.git
split "plugin/$REPO" $REPO
done
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then
git fetch origin "$CURRENT_BRANCH" || true
else
git pull --ff-only origin "$CURRENT_BRANCH"
fi
for repo in "${REPOS[@]}"; do
remote "$repo" "git@github.com:zoujingli/${repo}.git"
split_branch "$repo"
done

View File

@ -11,21 +11,30 @@
# test: 测试
###############################
name: Create Release
on:
push:
tags:
- 'v*' # 仅匹配 v* 版本标签,如 v1.0、v20.15.10
- 'v6.*'
- 'v8.*'
name: Create Release
permissions:
contents: write
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
verify:
if: github.repository == 'zoujingli/ThinkAdmin'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -41,45 +50,66 @@ jobs:
run: composer test
- name: Run static analysis
run: composer analyse
run: |
if php -r '$composer=json_decode(file_get_contents("composer.json"), true); exit(isset($composer["scripts"]["analyse"]) ? 0 : 1);'; then
composer analyse
else
echo "No composer analyse script, skip."
fi
release:
if: github.repository == 'zoujingli/ThinkAdmin'
needs:
- verify
runs-on: ubuntu-latest
env:
SSH_PRIVATE_KEY: ${{ secrets.SPLIT_PRIVATE_KEY }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve Tags
id: tags
- name: Resolve Release Context
run: |
CURRENT_TAG="${GITHUB_REF_NAME}"
PREVIOUS_TAG=$(git tag --list 'v*' --sort=-version:refname | grep -Fxv "$CURRENT_TAG" | head -n 1 || true)
case "$CURRENT_TAG" in
v6.*) MAJOR="6"; RELEASE_BRANCH="v6-dev" ;;
v8.*) MAJOR="8"; RELEASE_BRANCH="v8-dev" ;;
*) echo "Unsupported release tag: $CURRENT_TAG" >&2; exit 1 ;;
esac
git fetch origin "+refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}"
TAG_COMMIT="$(git rev-list -n 1 "$CURRENT_TAG")"
BRANCH_HEAD="$(git rev-parse "refs/remotes/origin/${RELEASE_BRANCH}")"
git merge-base --is-ancestor "$TAG_COMMIT" "$BRANCH_HEAD"
if [ "$TAG_COMMIT" != "$BRANCH_HEAD" ]; then
echo "Tag ${CURRENT_TAG} must point to latest ${RELEASE_BRANCH} head." >&2
echo "Tag commit: ${TAG_COMMIT}" >&2
echo "Branch head: ${BRANCH_HEAD}" >&2
exit 1
fi
PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match "v${MAJOR}.*" "${TAG_COMMIT}^" 2>/dev/null || true)
echo "CURRENT_TAG=$CURRENT_TAG" >> "$GITHUB_ENV"
echo "PREVIOUS_TAG=$PREVIOUS_TAG" >> "$GITHUB_ENV"
echo "RELEASE_BRANCH=$RELEASE_BRANCH" >> "$GITHUB_ENV"
- name: Generate Release Notes
run: python .github/generate-release-notes.py
- name: Setup Private Key
run: |
rm -rf log
mkdir -p log
{
echo "## Release ${CURRENT_TAG}"
echo
if [ -n "${PREVIOUS_TAG}" ]; then
echo "Compare: ${PREVIOUS_TAG}..${CURRENT_TAG}"
echo
git log --pretty=format:'- %s (%h)' "${PREVIOUS_TAG}..${CURRENT_TAG}"
else
echo "Initial tagged release."
echo
git log --pretty=format:'- %s (%h)' "${CURRENT_TAG}"
fi
echo
} > "log/${CURRENT_TAG}.md"
test -n "$SSH_PRIVATE_KEY"
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan github.com >> ~/.ssh/known_hosts
echo "StrictHostKeyChecking no" >> ~/.ssh/config
- name: Split Plugin Tags
run: ./.github/release.sh "${CURRENT_TAG}" "${RELEASE_BRANCH}"
- name: Create Release
uses: softprops/action-gh-release@v2
@ -88,4 +118,4 @@ jobs:
name: Release ${{ env.CURRENT_TAG }}
body_path: log/${{ env.CURRENT_TAG }}.md
draft: false
prerelease: false
prerelease: ${{ contains(env.CURRENT_TAG, '-') }}

View File

@ -7,6 +7,13 @@ on:
- v8-dev
workflow_dispatch:
permissions:
contents: read
concurrency:
group: split-${{ github.ref_name }}
cancel-in-progress: false
jobs:
split:
if: github.repository == 'zoujingli/ThinkAdmin'
@ -19,8 +26,16 @@ jobs:
with:
fetch-depth: 0
- name: Guard Branch
run: |
case "${GITHUB_REF_NAME}" in
v6-dev|v8-dev) ;;
*) echo "Unsupported split branch: ${GITHUB_REF_NAME}" >&2; exit 1 ;;
esac
- name: Setup Private Key
run: |
test -n "$SSH_PRIVATE_KEY"
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa