From 31f7be77a8c81ffe8a68d4a84ca86861b272705c Mon Sep 17 00:00:00 2001 From: Toshihiko SHIMOKAWA Date: Tue, 14 Jul 2026 15:05:33 +0900 Subject: [PATCH 1/3] feat: add sync-next-draft reusable workflow Automatically merge pushes on a draft branch (e.g. accepted review suggestions) into the following draft branches, walking the whole chain in one run. On merge conflict, open a draft-to-draft sync PR that students can resolve in the browser and notify the review PR. Also allow draft-to-draft sync PRs in prevent-draft-merge. Refs: smkwlab/sotsuron-template#110 --- .github/workflows/prevent-draft-merge.yml | 18 +- .github/workflows/sync-next-draft.yml | 206 ++++++++++++++++++++++ 2 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sync-next-draft.yml diff --git a/.github/workflows/prevent-draft-merge.yml b/.github/workflows/prevent-draft-merge.yml index 2874713..67d0dd2 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,18 @@ 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) + echo "::debug::Base branch: $BASE_REF" + if [[ "$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::Base branch is a draft branch ($BASE_REF) - sync PR" + exit 0 + fi + # Branch name validation if [[ -z "$BRANCH_NAME" ]]; then echo "::error::Branch name is empty" @@ -132,6 +144,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 +152,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..9c9640e --- /dev/null +++ b/.github/workflows/sync-next-draft.yml @@ -0,0 +1,206 @@ +# 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" attempt + for attempt in 1 2; do + 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 + gh pr create --base "$into" --head "$from" \ + --title "Sync review suggestions from $from into $into" \ + --body "$(conflict_pr_body "$from" "$into")" + sync_pr=$(gh pr list --head "$from" --base "$into" --state open \ + --json number --jq '.[0].number // empty') + fi + + local review_pr + review_pr=$(gh pr list --head "$from" --state open \ + --json number,baseRefName \ + --jq "[.[] | select(.baseRefName != \"$into\")][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 From af90342f0a11cbb4c6fc04eaf629f764bd090722 Mon Sep 17 00:00:00 2001 From: Toshihiko SHIMOKAWA Date: Tue, 14 Jul 2026 15:10:59 +0900 Subject: [PATCH 2/3] docs: document sync-next-draft workflow in README --- README.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) 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 From 4e1b4ea2110feb6f9169b2321510005c786db1fb Mon Sep 17 00:00:00 2001 From: Toshihiko SHIMOKAWA Date: Tue, 14 Jul 2026 15:20:34 +0900 Subject: [PATCH 3/3] fix: address review comments on sync-next-draft - Require both head and base to match draft patterns for the draft-to-draft sync PR allowance in prevent-draft-merge - Take the sync PR number from the gh pr create output instead of a second gh pr list (race-safe) - Select the review PR by excluding draft-pattern bases so sync PRs are never picked, including mid-chain conflicts - Clarify the retry behavior of sync_pair and fix SC2034 (unused loop variable) reported by actionlint Refs: smkwlab/sotsuron-template#110 --- .github/workflows/prevent-draft-merge.yml | 11 +++++++---- .github/workflows/sync-next-draft.yml | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/prevent-draft-merge.yml b/.github/workflows/prevent-draft-merge.yml index 67d0dd2..7271406 100644 --- a/.github/workflows/prevent-draft-merge.yml +++ b/.github/workflows/prevent-draft-merge.yml @@ -75,13 +75,16 @@ jobs: # Allow draft-to-draft sync PRs (e.g. created by # sync-next-draft.yml to carry accepted review suggestions - # into the next draft branch) + # into the next draft branch). Both head and base must match + # a draft pattern. echo "::debug::Base branch: $BASE_REF" - if [[ "$BASE_REF" =~ ^[0-9]+(st|nd|rd|th)-draft$ ]] || \ - [[ "$BASE_REF" =~ ^abstract-[0-9]+(st|nd|rd|th)$ ]]; then + 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::Base branch is a draft branch ($BASE_REF) - sync PR" + echo "::notice::Head and base are both draft branches - sync PR" exit 0 fi diff --git a/.github/workflows/sync-next-draft.yml b/.github/workflows/sync-next-draft.yml index 9c9640e..bcda0e6 100644 --- a/.github/workflows/sync-next-draft.yml +++ b/.github/workflows/sync-next-draft.yml @@ -96,8 +96,11 @@ jobs: # 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" attempt - for attempt in 1 2; do + 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" @@ -143,17 +146,19 @@ jobs: --json number --jq '.[0].number // empty') if [ -z "$sync_pr" ]; then - gh pr create --base "$into" --head "$from" \ + 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=$(gh pr list --head "$from" --base "$into" --state open \ - --json number --jq '.[0].number // empty') + --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 != \"$into\")][0].number // empty") + --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