Compare commits

..

3 Commits

Author SHA1 Message Date
Anyon
72c8c1e6f6 ci(split): 缺少拆分密钥时跳过插件同步
避免新仓库尚未配置 SPLIT_PRIVATE_KEY 时,v6-dev 与 v8-dev 的 Split Repositories 工作流直接失败。

主要内容:

- 增加拆分密钥预检查步骤。

- 未配置 SPLIT_PRIVATE_KEY 时输出 GitHub Actions warning 并跳过 Setup Private Key 与 Split And Push。

- 配置密钥后自动恢复插件仓库拆分推送流程。
2026-05-08 17:02:07 +08:00
Anyon
f2de95223a 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 脚本的情况,避免旧分支发布失败。
2026-05-08 15:56:55 +08:00
Anyon
cdd22b0abc ci(github): 更新 ThinkAdmin 自动拆分工作流
将插件拆分工作流的主仓库判断从 ThinkAdminDeveloper 切换为 ThinkAdmin,匹配当前 GitHub 仓库名称。

主要内容:

- 更新 github.repository 条件为 zoujingli/ThinkAdmin。

- 增加 v6-dev 与 v8-dev 分支 push 触发,推送开发分支后自动拆分同步插件仓库。

- 保留 workflow_dispatch 手动触发入口,便于补跑指定分支同步。
2026-05-08 15:46:09 +08:00
5 changed files with 383 additions and 133 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 #!/usr/bin/env bash
set -e set -euo pipefail
if (( "$#" == 0 ))
then if [[ $# -lt 1 ]]; then
echo "Tag has to be provided" echo "Usage: $0 <tag> [release-branch] [repo ...]" >&2
exit 1 exit 1
fi fi
NOW=$(date +%s) VERSION="$1"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) shift
VERSION=$1
BASEPATH=$(cd `dirname $0`; cd ../plugin/; pwd)
if [ -z $2 ] ; then if [[ $# -gt 0 && "$1" =~ ^v[0-9]+-dev$ ]]; then
repos=$(ls $BASEPATH) RELEASE_BRANCH="$1"
shift
else else
repos=${@:2} RELEASE_BRANCH="${RELEASE_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"
fi fi
for REMOTE in $repos case "$VERSION" in
do v6.*) EXPECTED_BRANCH="v6-dev" ;;
echo "" v8.*) EXPECTED_BRANCH="v8-dev" ;;
echo "" *)
echo "Cloning $REMOTE"; echo "Only v6.* and v8.* tags are allowed, current: ${VERSION}" >&2
TMP_DIR="/tmp/ThinkAdminSplit" exit 1
REMOTE_URL="git@github.com:zoujingli/$REMOTE.git" ;;
esac
rm -rf $TMP_DIR; if [[ "$RELEASE_BRANCH" != "$EXPECTED_BRANCH" ]]; then
mkdir $TMP_DIR; echo "Tag ${VERSION} must be released from ${EXPECTED_BRANCH}, current: ${RELEASE_BRANCH}" >&2
exit 1
fi
( BASEPATH="$(cd "$(dirname "$0")"; cd ../plugin/; pwd)"
cd $TMP_DIR; REPOS=("$@")
git clone $REMOTE_URL . remote() {
git checkout "$CURRENT_BRANCH"; 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 push_split_release() {
echo "Releasing $REMOTE" local repo="$1"
git tag $VERSION local prefix="plugin/${repo}"
git push origin --tags local sha1 remote_tag
fi
) 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 done
TIME=$(echo "$(date +%s) - $NOW" | bc)
printf "Execution time: %f seconds" $TIME

View File

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

@ -1,75 +1,121 @@
####### 可解析的提交前缀 ######## ####### 可解析的提交前缀 ########
# ci: 持续集成 # ci: 持续集成
# fix: 修改 # fix: 修改
# feat: 新增 # feat: 新增
# refactor: 重构 # refactor: 重构
# docs: 文档 # docs: 文档
# style: 样式 # style: 样式
# chore: 其他 # chore: 其他
# build: 构建 # build: 构建
# pref: 优化 # pref: 优化
# test: 测试 # test: 测试
############################### ###############################
on: name: Create Release
push:
tags: on:
- 'v*' # 仅匹配 v* 版本标签,如 v1.0、v20.15.10 push:
tags:
name: Create Release - 'v6.*'
permissions: write-all - 'v8.*'
jobs: permissions:
release: contents: write
runs-on: ubuntu-latest
steps: concurrency:
- name: Checkout code group: release-${{ github.ref_name }}
uses: actions/checkout@v4 cancel-in-progress: false
with:
fetch-depth: 0 jobs:
verify:
- name: Setup Node.js if: github.repository == 'zoujingli/ThinkAdmin'
uses: actions/setup-node@v4 runs-on: ubuntu-latest
with: steps:
node-version: 18 - name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies with:
run: npm install -g gen-git-log fetch-depth: 0
- name: Find Last Tag - name: Setup PHP
id: last_tag uses: shivammathur/setup-php@v2
run: | with:
# 获取所有标签,按版本号降序排序 php-version: '8.1'
all_tags=$(git tag --list --sort=-version:refname) extensions: bcmath, curl, gd, mbstring, openssl
coverage: none
# 获取最新的标签
LATEST_TAG=$(echo "$all_tags" | head -n 1) - name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
# 获取倒数第二个标签(如果有)
SECOND_LATEST_TAG=$(echo "$all_tags" | sed -n '2p') - name: Run tests
run: composer test
# 如果没有任何标签,默认 v1.0.0
LATEST_TAG=${LATEST_TAG:-v1.0.0} - name: Run static analysis
SECOND_LATEST_TAG=${SECOND_LATEST_TAG:-v1.0.0} run: |
if php -r '$composer=json_decode(file_get_contents("composer.json"), true); exit(isset($composer["scripts"]["analyse"]) ? 0 : 1);'; then
# 设置环境变量 composer analyse
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV else
echo "SECOND_LATEST_TAG=$SECOND_LATEST_TAG" >> $GITHUB_ENV echo "No composer analyse script, skip."
fi
- name: Generate Release Notes
run: | release:
rm -rf log if: github.repository == 'zoujingli/ThinkAdmin'
mkdir -p log needs:
git-log -m tag -f -S $SECOND_LATEST_TAG -v ${LATEST_TAG#v} - verify
runs-on: ubuntu-latest
- name: Create Release env:
id: create_release SSH_PRIVATE_KEY: ${{ secrets.SPLIT_PRIVATE_KEY }}
uses: actions/create-release@v1 steps:
env: - name: Checkout code
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: actions/checkout@v4
with: with:
tag_name: ${{ env.LATEST_TAG }} fetch-depth: 0
release_name: Release ${{ env.LATEST_TAG }}
body_path: log/${{ env.LATEST_TAG }}.md - name: Resolve Release Context
draft: false run: |
prerelease: false CURRENT_TAG="${GITHUB_REF_NAME}"
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: |
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
with:
tag_name: ${{ env.CURRENT_TAG }}
name: Release ${{ env.CURRENT_TAG }}
body_path: log/${{ env.CURRENT_TAG }}.md
draft: false
prerelease: ${{ contains(env.CURRENT_TAG, '-') }}

View File

@ -1,10 +1,22 @@
name: Split Repositorys name: Split Repositories
on: [ workflow_dispatch ] on:
push:
branches:
- v6-dev
- v8-dev
workflow_dispatch:
permissions:
contents: read
concurrency:
group: split-${{ github.ref_name }}
cancel-in-progress: false
jobs: jobs:
split: split:
if: github.repository == 'zoujingli/ThinkAdminDeveloper' if: github.repository == 'zoujingli/ThinkAdmin'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SPLIT_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SPLIT_PRIVATE_KEY }}
@ -14,7 +26,25 @@ jobs:
with: with:
fetch-depth: 0 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: Check Split Secret
id: split_secret
run: |
if [ -z "$SSH_PRIVATE_KEY" ]; then
echo "::warning title=Skip Split::Repository secret SPLIT_PRIVATE_KEY is not configured, skip plugin repository split."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Private Key - name: Setup Private Key
if: steps.split_secret.outputs.enabled == 'true'
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
@ -23,6 +53,7 @@ jobs:
echo "StrictHostKeyChecking no" >> ~/.ssh/config echo "StrictHostKeyChecking no" >> ~/.ssh/config
- name: Split And Push - name: Split And Push
if: steps.split_secret.outputs.enabled == 'true'
run: | run: |
git config pull.rebase true git config pull.rebase true
git config --global user.name "Anyon" git config --global user.name "Anyon"