diff --git a/.github/workflows/prevent-draft-merge.yml b/.github/workflows/prevent-draft-merge.yml index 2874713..7271406 100644 --- a/.github/workflows/prevent-draft-merge.yml +++ b/.github/workflows/prevent-draft-merge.yml @@ -1,4 +1,3 @@ ---- # Reusable Workflow: Prevent Draft Branch Merge # # This workflow prevents accidental merging of draft branches. @@ -44,6 +43,7 @@ jobs: id: check-draft env: HEAD_REF: ${{ github.head_ref }} + BASE_REF: ${{ github.base_ref }} run: | set -e @@ -73,6 +73,21 @@ jobs: fi fi + # Allow draft-to-draft sync PRs (e.g. created by + # sync-next-draft.yml to carry accepted review suggestions + # into the next draft branch). Both head and base must match + # a draft pattern. + echo "::debug::Base branch: $BASE_REF" + if { [[ "$BRANCH_NAME" =~ ^[0-9]+(st|nd|rd|th)-draft$ ]] || \ + [[ "$BRANCH_NAME" =~ ^abstract-[0-9]+(st|nd|rd|th)$ ]]; } && \ + { [[ "$BASE_REF" =~ ^[0-9]+(st|nd|rd|th)-draft$ ]] || \ + [[ "$BASE_REF" =~ ^abstract-[0-9]+(st|nd|rd|th)$ ]]; }; then + { echo "is_draft=false"; echo "draft_type=sync"; } >> "$GITHUB_OUTPUT" + echo "Draft-to-draft sync PR - merge allowed" + echo "::notice::Head and base are both draft branches - sync PR" + exit 0 + fi + # Branch name validation if [[ -z "$BRANCH_NAME" ]]; then echo "::error::Branch name is empty" @@ -132,6 +147,7 @@ jobs: HEAD_REF: ${{ github.head_ref }} IS_FINAL: ${{ steps.check-draft.outputs.is_final_submission }} FINAL_TAG: ${{ steps.check-draft.outputs.final_tag }} + DRAFT_TYPE: ${{ steps.check-draft.outputs.draft_type }} run: | BRANCH_NAME="$HEAD_REF" @@ -139,6 +155,9 @@ jobs: echo "Final submission branch - merge allowed" echo "Branch name: $BRANCH_NAME" echo "Final submission tag: $FINAL_TAG" + elif [[ "$DRAFT_TYPE" == "sync" ]]; then + echo "Draft-to-draft sync PR - merge allowed" + echo "Branch name: $BRANCH_NAME" else echo "Regular branch - merge allowed" echo "Branch name: $BRANCH_NAME" diff --git a/.github/workflows/sync-next-draft.yml b/.github/workflows/sync-next-draft.yml new file mode 100644 index 0000000..bcda0e6 --- /dev/null +++ b/.github/workflows/sync-next-draft.yml @@ -0,0 +1,211 @@ +# Reusable Workflow: Sync Next Draft Branch +# +# When commits are pushed to a draft branch (e.g. a student accepts +# review suggestions on its PR), this workflow merges them into the +# following draft branch(es) so students always continue writing on +# top of the reviewed text. +# +# Pushes made with GITHUB_TOKEN do not trigger new workflow runs, so a +# single run walks the whole chain (1st-draft -> 2nd-draft -> ...). +# On merge conflict, it opens a draft-to-draft sync PR (resolvable in +# the browser) and notifies the review PR of the pushed branch. +# +# Use this from other repositories with: +# +# on: +# push: +# branches: +# - '*-draft' +# - 'abstract-*' +# jobs: +# sync: +# permissions: +# contents: write +# pull-requests: write +# uses: smkwlab/.github/.github/workflows/sync-next-draft.yml@v1 +# +name: Sync Next Draft Branch + +on: + workflow_call: + +permissions: + contents: write + pull-requests: write + +jobs: + sync-next-branch: + runs-on: ubuntu-latest + + # Defensive guard against bot loops (GITHUB_TOKEN pushes do not + # trigger workflows in the first place) + if: github.actor != 'github-actions[bot]' + + concurrency: + group: sync-next-draft-${{ github.repository }} + cancel-in-progress: false + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + token: ${{ github.token }} + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Sync pushed changes into following draft branches + env: + GH_TOKEN: ${{ github.token }} + PUSHED_BRANCH: ${{ github.ref_name }} + run: | + set -eu + + # Ordinal suffix for a number (1 -> 1st, 2 -> 2nd, ...) + ordinal() { + local n="$1" + local last_two=$((n % 100)) + local last_one=$((n % 10)) + if [[ $last_two -ge 11 && $last_two -le 13 ]]; then + echo "${n}th" + elif [[ $last_one -eq 1 ]]; then + echo "${n}st" + elif [[ $last_one -eq 2 ]]; then + echo "${n}nd" + elif [[ $last_one -eq 3 ]]; then + echo "${n}rd" + else + echo "${n}th" + fi + } + + # Draft branch following the given one (empty if no draft pattern) + next_branch() { + local branch="$1" + if [[ "$branch" =~ ^([0-9]+)(st|nd|rd|th)-draft$ ]]; then + # Also covers the 0th-draft -> 1st-draft special case + echo "$(ordinal $((BASH_REMATCH[1] + 1)))-draft" + elif [[ "$branch" =~ ^abstract-([0-9]+)(st|nd|rd|th)$ ]]; then + echo "abstract-$(ordinal $((BASH_REMATCH[1] + 1)))" + fi + } + + # Merge "from" into "into" and push; retry once on push race. + # Returns 1 on merge conflict, 2 when the push keeps failing. + sync_pair() { + local from="$1" into="$2" + for _ in 1 2; do + # (Re)start from the freshly fetched remote state; on a + # retry after a rejected push, checkout -B discards the + # previous local merge and redoes it on the new tip + git fetch origin \ + "+refs/heads/$from:refs/remotes/origin/$from" \ + "+refs/heads/$into:refs/remotes/origin/$into" + git checkout -B "$into" "origin/$into" + if ! git merge --no-edit -m "Merge $from into $into (sync review suggestions)" "origin/$from"; then + git merge --abort || true + return 1 + fi + if git push origin "$into"; then + return 0 + fi + echo "Push of $into was rejected (concurrent update), retrying..." + done + return 2 + } + + conflict_pr_body() { + local from="$1" into="$2" + cat <>>>>>>\` までの箇所を探し、残したい文になるように編集する + 3. **Mark as resolved** を押してから **Commit merge** を押す + 4. この PR を **Merge pull request** で merge する(この同期 PR は merge してかまいません) + + merge が完了すると、以降の suggestion は再び自動で「$into」へ取り込まれます。 + EOF + } + + # On conflict: open a draft-to-draft sync PR and notify the + # review PR of the pushed branch. + handle_conflict() { + local from="$1" into="$2" + echo "::warning::Merge conflict while syncing $from into $into" + + local sync_pr + sync_pr=$(gh pr list --head "$from" --base "$into" --state open \ + --json number --jq '.[0].number // empty') + + if [ -z "$sync_pr" ]; then + local pr_url + pr_url=$(gh pr create --base "$into" --head "$from" \ + --title "Sync review suggestions from $from into $into" \ + --body "$(conflict_pr_body "$from" "$into")") + sync_pr="${pr_url##*/}" + fi + + # The review PR of "from" is the open PR whose base is not a + # draft branch (draft-based PRs are sync PRs, also mid-chain) + local review_pr + review_pr=$(gh pr list --head "$from" --state open \ + --json number,baseRefName \ + --jq '[.[] | select(.baseRefName | test("^[0-9]+(st|nd|rd|th)-draft$|^abstract-[0-9]+(st|nd|rd|th)$") | not)][0].number // empty') + if [ -n "$review_pr" ]; then + gh pr comment "$review_pr" --body "受け入れた suggestion を「$into」へ自動 merge できませんでした(コンフリクト)。同期 PR #$sync_pr を開き、「Resolve conflicts」で解決してから merge してください。" + fi + + { + echo "## Sync stopped by merge conflict" + echo "" + echo "- $from -> $into: conflict, see sync PR #$sync_pr" + } >> "$GITHUB_STEP_SUMMARY" + } + + current="$PUSHED_BRANCH" + synced="" + + while true; do + next="$(next_branch "$current")" + if [ -z "$next" ]; then + echo "::notice::No next draft branch pattern for $current; done" + break + fi + if ! git ls-remote --exit-code --heads origin "$next" > /dev/null; then + echo "::notice::Next draft branch $next does not exist; done" + break + fi + + status=0 + sync_pair "$current" "$next" || status=$? + + if [ "$status" -eq 1 ]; then + handle_conflict "$current" "$next" + exit 0 + elif [ "$status" -ne 0 ]; then + echo "::error::Could not push $next (kept failing due to concurrent updates)" + exit 1 + fi + + echo "Synced $current -> $next" + synced="$synced $next" + current="$next" + done + + if [ -n "$synced" ]; then + { + echo "## Synced draft branches" + echo "" + for b in $synced; do + echo "- $b" + done + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/README.md b/README.md index d827e5d..26d93f3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ smkwlab organization の共通設定および Reusable Workflows を管理する | Workflow | 説明 | 用途 | |----------|------|------| | `create-next-draft.yml` | PR 作成時に次の draft ブランチを自動作成 | 卒論・ISE レポート | +| `sync-next-draft.yml` | draft ブランチへの push(suggestion 受け入れ等)を後続の draft ブランチへ自動 merge | 卒論・ISE レポート | | `prevent-draft-merge.yml` | draft ブランチの誤マージを防止 | 卒論・ISE レポート | | `auto-final-merge.yml` | final-* タグ push 時に承認済み PR を自動マージ | 卒論テンプレート | | `ai-review.yml` | ワンショット LLM(Claude/Gemini)による PR 自動レビュー(CODE / ACADEMIC) | 全テンプレート | @@ -384,9 +385,40 @@ draft ブランチからの PR 作成時に、次の draft ブランチを自動 - `abstract-1st` → `abstract-2nd` → ... - `0th-draft` → `1st-draft` +### sync-next-draft.yml + +draft ブランチへ push されたコミット(レビュー PR で suggestion を受け入れた場合を含む)を、後続の draft ブランチへ連鎖的に自動 merge します。PR 作成時に次稿ブランチが先行作成されるため、その後に受け入れた suggestion が次稿に反映されない問題(sotsuron-template#110)への対応です。 + +**動作:** + +- `1st-draft` への push → `2nd-draft` が存在すれば merge → `3rd-draft` … と存在する限り伝播(GITHUB_TOKEN による push は新しい workflow run を発火しないため、1 回の実行でチェーン全体を処理) +- コンフリクト時: 前稿→次稿の同期 PR(例: head `1st-draft` / base `2nd-draft`)を自動作成し、前稿のレビュー PR へコメントで通知。学生はブラウザの「Resolve conflicts」で解決して同期 PR を merge する(`prevent-draft-merge.yml` は draft→draft の同期 PR を許可) +- 成功時は PR コメントしない(Actions の step summary にのみ記録) + +**caller 例:** + +```yaml +name: Sync Suggestions to Next Draft +on: + push: + branches: + - '*-draft' + - 'abstract-*' + +jobs: + sync: + permissions: + contents: write + pull-requests: write + uses: smkwlab/.github/.github/workflows/sync-next-draft.yml@v1 +``` + +> [!NOTE] +> `push` トリガーは **push されたブランチ上の**ワークフローファイルを参照します。既存リポジトリへ配布する際は default branch だけでなく、既存の draft ブランチにも caller を配置してください(`registry-manager propagate-workflow` 等で伝播)。 + ### prevent-draft-merge.yml -draft ブランチの誤マージを防止します。`final-*` タグが付いている場合のみマージを許可。 +draft ブランチの誤マージを防止します。`final-*` タグが付いている場合のみマージを許可。また、base も draft ブランチである同期 PR(`sync-next-draft.yml` がコンフリクト時に作成)はマージを許可します。 ### auto-final-merge.yml