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