From 1ddee14de684fd2df483733b5e8987f900f45e59 Mon Sep 17 00:00:00 2001 From: Anyon Date: Fri, 8 May 2026 15:54:58 +0800 Subject: [PATCH] =?UTF-8?q?ci(release):=20=E5=AE=8C=E5=96=84=E5=8F=8C?= =?UTF-8?q?=E5=88=86=E6=94=AF=E8=87=AA=E5=8A=A8=E6=8B=86=E5=88=86=E4=B8=8E?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 ThinkAdmin 主仓库补强 v6-dev 与 v8-dev 的自动化处理机制,避免两个开发分支在插件仓库分支、发布 Tag 与 Release Notes 生成时互相覆盖。 主要内容: - 拆分工作流仅允许 v6-dev 与 v8-dev,按同名分支推送各插件仓库。 - 发布工作流按 v6.* / v8.* Tag 自动选择对应开发分支,并要求 Tag 指向对应分支最新提交。 - 发布脚本同步推送插件分支和同名 Tag,遇到冲突 Tag 会拒绝覆盖。 - 新增中文 Release Notes 生成器,按提交前缀自动汇总本次变更内容。 - 静态分析步骤兼容 v6-dev 未定义 analyse 脚本的情况,避免旧分支发布失败。 --- .github/generate-release-notes.py | 118 ++++++++++++++++++++++++++++++ .github/release.sh | 101 ++++++++++++++++--------- .github/split-linux.sh | 64 +++++++++++----- .github/workflows/release.yml | 76 +++++++++++++------ .github/workflows/split.yml | 15 ++++ 5 files changed, 296 insertions(+), 78 deletions(-) create mode 100755 .github/generate-release-notes.py mode change 100644 => 100755 .github/release.sh diff --git a/.github/generate-release-notes.py b/.github/generate-release-notes.py new file mode 100755 index 000000000..7931d6841 --- /dev/null +++ b/.github/generate-release-notes.py @@ -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[a-z]+)(?:\((?P[^)]+)\))?[::]\s*(?P.+)$") + + +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("- 插件仓库会同步推送同名分支和同名 Tag,Packagist 可通过 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() diff --git a/.github/release.sh b/.github/release.sh old mode 100644 new mode 100755 index e07d7c104..cc6a9479b --- a/.github/release.sh +++ b/.github/release.sh @@ -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 \ No newline at end of file diff --git a/.github/split-linux.sh b/.github/split-linux.sh index fa41a66de..461a67142 100755 --- a/.github/split-linux.sh +++ b/.github/split-linux.sh @@ -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 \ No newline at end of file +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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef21c7c6a..3ec4cb303 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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, '-') }} diff --git a/.github/workflows/split.yml b/.github/workflows/split.yml index a9792b2a4..7a141fc4f 100644 --- a/.github/workflows/split.yml +++ b/.github/workflows/split.yml @@ -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