From 7e3f8348879aea0cac156aaeb9066a8a8999bf6c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 11:46:08 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(ndf):=20=E6=98=87=E6=A0=BC=E3=81=AE?= =?UTF-8?q?=E6=89=BF=E8=AA=8D=E3=81=AE=E5=BE=8C=E3=81=AE=E3=83=9E=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=92=E6=89=BF=E8=AA=8D=E3=82=B2=E3=83=BC=E3=83=88?= =?UTF-8?q?=202=20=E3=81=AE=E6=89=BF=E8=AA=8D=E8=B3=87=E6=96=99=E3=81=AE?= =?UTF-8?q?=E5=85=88=E7=AB=AF=E3=81=A8=E7=85=A7=E5=90=88=E3=81=99=E3=82=8B?= =?UTF-8?q?=EF=BC=88#2104=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normal の promote は承認ゲート 2 で止まるときに昇格の PR の先端を「承認したコミット」として 承認資料と metrics.approved_sha に載せる。promote-approved は --approved-from の資料の SHA と 先端を照合し、違えば head_moved で止まって承認ゲート 2 からのやり直しを next に示す。 資料を読めないときと、--gate-approved user に資料が無いときは gh を呼ばずに 2 で止まる。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/merged-steps.py | 15 ++- plugins/ndf/scripts/merged_lib/checks.py | 4 +- plugins/ndf/scripts/merged_lib/merge.py | 66 +++++++++++-- .../supervise_lib/delivery_templates.py | 11 ++- .../tests/test_sprint_close_merge_green.py | 94 ++++++++++++++++++- .../tests/test_supervise_pace_verify.py | 8 +- 6 files changed, 175 insertions(+), 23 deletions(-) diff --git a/plugins/ndf/scripts/merged-steps.py b/plugins/ndf/scripts/merged-steps.py index 4ca87bdb6..a5f5719b4 100755 --- a/plugins/ndf/scripts/merged-steps.py +++ b/plugins/ndf/scripts/merged-steps.py @@ -6,7 +6,8 @@ python3 merged-steps.py merge-gate (--base <宛先> | --pr ) [--pr ] [--root ] python3 merged-steps.py merge-when-green [--gate-approved user|mvv] [--method merge|squash|rebase] [--interval 秒] [--timeout 秒] [--stale-after 秒] [--no-cleanup] [--expect-head ] [--root ] - python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--gate-approved user|mvv] [--expect-head-from <資料>] + python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--out <資料>] [--gate-approved user|mvv] + [--expect-head-from <資料> | --approved-from <資料>] cleanup: マージ済みの PR の作業ツリーとローカルブランチを外し、主ディレクトリを取り込む。 `git worktree remove` が拒否した作業ツリーは、未追跡・無視されたファイルを退避してから外す。作り直せる生成物 @@ -21,6 +22,8 @@ merge-gate: 宛先の判定だけを行う(0 = 進めてよい / 10 = 承認ゲート 2)。 promote: 昇格の Pull Request(ベースブランチ → 本番チャネル)を探すか作り、merge-when-green と同じ判定と待ちでマージする(後片付けはしない)。 --prepare は用意して承認資料を書くところで 0 で終える。--expect-head-from は導入の確認の承認資料の確かめたコミットを --expect-head と同じ照合に使う(違えば head_moved で 1、読めなければ 2。#1529)。 +承認ゲート 2 で止まるときは、昇格の Pull Request の先端を「承認したコミット」として承認資料(--out)と metrics.approved_sha に載せる。 +--approved-from はその承認資料の承認したコミットを同じ照合に使う。--gate-approved user は --approved-from が無ければ 2 で止まる(#2104)。 実行が終わったのにチェックが pending のまま --stale-after 秒続けば、そのジョブを 1 度だけ `gh run rerun --job` で再実行し、 再実行でも取り残されれば止まる。実行が終わりジョブに結論があれば、チェックの表示が pending のままでも待たずにその結論で扱う。 ジョブがランナーを待つ間は、待ち行列の件数を待ちの 1 周ごとに stderr へ 1 行出す。同じチェック(workflow の名前と名前の組)は最新の項目だけで数え、古い項目の失敗は items に result: superseded で残す。 @@ -477,8 +480,14 @@ def build_parser(): pm.add_argument("--head", required=True, help="昇格させるブランチ(ベースブランチ)") pm.add_argument("--base", required=True, help="本番チャネル") pm.add_argument("--prepare", action="store_true", help="Pull Request を用意して承認資料を書くところで終える(マージしない)") - pm.add_argument("--out", help="--prepare の承認資料の置き場(既定は提示物の置き場)") - pm.add_argument("--expect-head-from", metavar="資料", help="導入の確認の承認資料。確かめたコミットが PR の先端のときだけマージする") + pm.add_argument("--out", help="--prepare と承認ゲート 2 の停止で書く昇格の承認資料の置き場(既定は提示物の置き場)") + pin = pm.add_mutually_exclusive_group() + pin.add_argument("--expect-head-from", metavar="資料", help="導入の確認の承認資料。確かめたコミットが PR の先端のときだけマージする") + pin.add_argument( + "--approved-from", + metavar="資料", + help="昇格の承認資料。承認したコミットが PR の先端のときだけマージする(--gate-approved user に要る)", + ) merge.add_wait_args(pm) pm.set_defaults(func=cmd_promote) pr = sub.add_parser( diff --git a/plugins/ndf/scripts/merged_lib/checks.py b/plugins/ndf/scripts/merged_lib/checks.py index 41d6ed82e..10902d79c 100644 --- a/plugins/ndf/scripts/merged_lib/checks.py +++ b/plugins/ndf/scripts/merged_lib/checks.py @@ -308,7 +308,8 @@ def _stop_conflict(self, why: str = "mergeStateStatus=DIRTY"): def _check_expected(self, sha, state): """--expect-head を渡したとき、読んだ先端がその SHA と違えば、待ち続けず・マージ済みでもそこで止める(#815 の I5)。 - 待つ間に別の先端でマージされた PR を成功として返さない(release が承認していないマージへタグを打たない)。""" + 待つ間に別の先端でマージされた PR を成功として返さない(release が承認していないマージへタグを打たない)。 + 呼び手が続け方(a.moved_next。promote の --approved-from。#2104)を渡していれば next に載せる。""" expect = getattr(self.a, "expect_head", None) if not expect or sha == expect: return @@ -320,6 +321,7 @@ def _check_expected(self, sha, state): f"#{n} の先端が {expect[:8]} でない(今は {str(sha)[:8]}・{state})。マージしない", self.items + [{"kind": "pr", "name": f"#{n}", "result": "head_moved", "head": sha, "expected": expect}], {"waits": self.waits}, + next=getattr(self.a, "moved_next", None), ) ) diff --git a/plugins/ndf/scripts/merged_lib/merge.py b/plugins/ndf/scripts/merged_lib/merge.py index a5d9538fd..8254d966d 100644 --- a/plugins/ndf/scripts/merged_lib/merge.py +++ b/plugins/ndf/scripts/merged_lib/merge.py @@ -71,8 +71,11 @@ def _judge_rows(verdict): return [("判定", verdict.value), ("理由", verdict.reason), *[(i["name"], i["result"]) for i in verdict.items]] -def gate_stop(n, verdict, info=None, next_cmd=None, plan_step="merge-approved"): - """承認の無い本番系へのマージを止める結果(status: gate・終了コード 10)を出して終える。承認資料を書く。""" +def gate_stop(n, verdict, info=None, next_cmd=None, plan_step="merge-approved", approved_sha=None, out=None): + """承認の無い本番系へのマージを止める結果(status: gate・終了コード 10)を出して終える。承認資料を書く。 + approved_sha(昇格の Pull Request の先端。#2104)があれば、判断の表の先頭へ「承認したコミット」の行を置き、 + metrics.approved_sha と gate の items[].head にも載せる。out は承認資料の置き場(無ければ既定の置き場)。 + next_cmd は文字列か、書いた承認資料のパスを受けて打ち方を返す関数。""" info = info or {} target = verdict.target or "(不明)" what = f"#{n}" if n else f"宛先 {target} へのマージ" @@ -93,19 +96,26 @@ def gate_stop(n, verdict, info=None, next_cmd=None, plan_step="merge-approved"): title=f"{what} のマージ(本番系への反映。承認ゲート 2)", targets=[target_row], change=change, - judge=[*_judge_rows(verdict), ("宛先", target)], + judge=[*([approved_commit.material_row(approved_sha)] if approved_sha else []), *_judge_rows(verdict), ("宛先", target)], consent=[f"{what} を {target} へマージし、本番系へ反映する"], rollback=GATE_ROLLBACK, + path=out, ) + if callable(next_cmd): + next_cmd = next_cmd(path) cmd = next_cmd or (f"merge-when-green {n} --gate-approved user" if n else "merge-when-green --gate-approved user") - items = verdict.items + [{"kind": "pr", "name": what, "result": "gate", "base": target}] + item = {"kind": "pr", "name": what, "result": "gate", "base": target} + metrics = {"gate": GATE, "verdict": verdict.value, "target": target} + if approved_sha: + item["head"] = metrics["approved_sha"] = approved_sha + items = verdict.items + [item] emit( result( TOOL, "gate", f"{lead}。承認ゲート 2 の承認が要る", items, - {"gate": GATE, "verdict": verdict.value, "target": target}, + metrics, path, f"承認を得たら {cmd}(プランなら run <プラン> --from {plan_step})", ) @@ -207,7 +217,16 @@ def _merge_pr(root, a, n, watch, items, waits): items.append(merged) -def merge_when_green(a, cleanup, next_cmd=None, plan_step="merge-approved"): +def _gate_head(info): + """承認ゲート 2 で止まる判定に使った読みの先端(承認資料へ載せる承認したコミット)。読めなければ止まる(#2104 の I1)。""" + try: + return approved_commit.parse_sha(info.get("headRefOid")) + except ValueError as e: + raise StepError(f"昇格の Pull Request の先端を読めないため承認資料を書かない: {e}", EXIT_UNREADABLE) from e + + +def merge_when_green(a, cleanup, next_cmd=None, plan_step="merge-approved", gate_head=False): + """gate_head なら、承認ゲート 2 で止まるときに最初の読みの先端を承認資料へ載せる(promote。#2104)。""" root = git_root(a.root) n = a.pr decl = delivery.load_delivery(root) # 判定は保存しない。呼ぶたびに宣言から作り直す @@ -219,7 +238,8 @@ def gate(info): verdict = delivery.judge_target(decl, info.get("baseRefName") or "") judged["verdict"] = verdict if verdict.stops and not a.gate_approved: - gate_stop(n, verdict, info, next_cmd, plan_step) + sha = _gate_head(info) if gate_head else None + gate_stop(n, verdict, info, next_cmd, plan_step, sha, getattr(a, "out", None) if gate_head else None) watch.on_open = gate watch.wait() @@ -286,7 +306,7 @@ def _prepare_promote(root, a, n, item): title=f"#{n} の昇格({a.head} → {a.base}。承認ゲート 2)", targets=[{"url": info.get("url") or f"#{n}", "title": info.get("title"), "base_head": f"{a.base} ← {a.head}"}], change=_change_text(info), - judge=_judge_rows(verdict), + judge=[approved_commit.material_row(_gate_head(info)), *_judge_rows(verdict)], consent=[f"#{n} を {a.base} へマージし、本番系へ反映する"], rollback=GATE_ROLLBACK, path=a.out, @@ -307,17 +327,43 @@ def promote(a, cleanup): """ベースブランチ(--head)から本番チャネル(--base)への昇格の Pull Request を作り(あれば使い)、承認ゲート 2 の後にマージする。 head はベースブランチなので、マージの後に後片付けをしない(#1336 の I7)。 --expect-head-from があれば、最初に導入の確認の承認資料から確かめたコミットを読み、merge-when-green の - --expect-head と同じ照合に使う。読めなければ gh を呼ばずに EXIT_UNREADABLE で止まる(照合を飛ばさない。#1529)。""" + --expect-head と同じ照合に使う。読めなければ gh を呼ばずに EXIT_UNREADABLE で止まる(照合を飛ばさない。#1529)。 + --approved-from があれば、昇格の承認資料の承認したコミットを同じ照合に使い、先端が違えば承認ゲート 2 から + やり直す打ち方を next に載せる。--gate-approved user は --approved-from 無しでは受けない(#2104 の I3〜I5)。""" src = getattr(a, "expect_head_from", None) if src: try: a.expect_head = approved_commit.verified_sha(src) except ValueError as e: raise StepError(f"確かめたコミットを読めないため昇格しない: {e}", EXIT_UNREADABLE) from e + approved_from = getattr(a, "approved_from", None) + if a.gate_approved == "user" and not approved_from: + emit( + result( + TOOL, + "stopped", + "--gate-approved user に照合の材料(--approved-from の昇格の承認資料)が無いため昇格しない", + next=( + f"promote --head {a.head} --base {a.base} --gate-approved user --approved-from <承認資料> で打つ" + "(プランなら run <プラン> --from promote から流し直し、承認ゲート 2 を取り直す)" + ), + ), + EXIT_UNREADABLE, + ) + if approved_from: + try: + a.expect_head = approved_commit.material_sha(approved_from) + except ValueError as e: + raise StepError(f"承認したコミットを読めないため昇格しない: {e}", EXIT_UNREADABLE) from e + a.moved_next = ( + "承認ゲート 2 からやり直す: プランなら run <プラン> --from promote(fast / auto は --from verify)。" + "承認資料を新しい先端で作り直してから承認を求める" + ) root = git_root(a.root) n, created = _find_or_create_promote_pr(root, a.head, a.base) item = {"kind": "pr", "name": f"#{n}", "result": "created" if created else "found", "base": a.base, "head_branch": a.head} if a.prepare: _prepare_promote(root, a, n, item) a.pr, a.no_cleanup = n, True - merge_when_green(a, cleanup, next_cmd=f"promote --head {a.head} --base {a.base} --gate-approved user", plan_step="promote-approved") + approve = f"promote --head {a.head} --base {a.base} --gate-approved user --approved-from " + merge_when_green(a, cleanup, next_cmd=lambda path: approve + str(path), plan_step="promote-approved", gate_head=True) diff --git a/plugins/ndf/scripts/supervise_lib/delivery_templates.py b/plugins/ndf/scripts/supervise_lib/delivery_templates.py index 4ee4eb5f9..f0593df1f 100644 --- a/plugins/ndf/scripts/supervise_lib/delivery_templates.py +++ b/plugins/ndf/scripts/supervise_lib/delivery_templates.py @@ -16,6 +16,8 @@ VERIFY_MATERIAL = "{state_dir}/work/approval-verify.md" # verify-facts が書く承認資料(#1457) +# 昇格の承認資料。normal は promote の承認ゲート 2 の停止が、fast / auto は prepare が書き、promote-approved が照合に読む(#2104) +PROMOTE_MATERIAL = "{state_dir}/work/approval-promote.md" def facts_step(repo: str, cmd: str, next_id: str, step_id: str = "facts") -> dict: @@ -66,7 +68,8 @@ def plan_promote( マージの後に record(`release-steps.py record --promote`)が昇格の PR へリリース記録を書き、その PR を計画の Pull Request にする(まとめの close が `{queue_pr:promote}` で読む。#837)。record が落ちたら judge-record へ進む。 - - normal(`mvv` 無し): promote(承認の引数無し。承認ゲート 2 で終える)→ promote-approved(`--from` でだけ入る) + - normal(`mvv` 無し): promote(承認の引数無し。昇格の承認資料 PROMOTE_MATERIAL へ先端を載せて承認ゲート 2 で終える)→ + promote-approved(`--from` でだけ入る。PROMOTE_MATERIAL の承認したコミットのときだけマージする。#2104) - fast / auto(`mvv` にスプリントの状態): verify(導入の確認。落ちたら止まる。#1457)→ prepare(PR と承認資料)→ mvv(承認ゲート 2 の MVV 判定)→ note(判定のコメント)→ promote(`--gate-approved mvv`)。promote は mvv と同じ導入の確認の承認資料(VERIFY_MATERIAL)を `--expect-head-from` に受け、 確かめたコミットのときだけマージする(#1529)。note か promote が落ちたら handoff が承認ゲートへ落とす @@ -82,7 +85,7 @@ def plan_promote( "type": "run", "stage": "配布", "timeout": timeout, - "cmd": promote + wait + " --gate-approved user", + "cmd": promote + wait + f" --approved-from {PROMOTE_MATERIAL} --gate-approved user", "next": "record", } record = [ @@ -106,7 +109,7 @@ def plan_promote( "type": "run", "stage": "配布", "timeout": timeout, - "cmd": promote + wait, + "cmd": promote + wait + f" --out {PROMOTE_MATERIAL}", "gate_next": "end", "next": "record", }, @@ -117,7 +120,7 @@ def plan_promote( if not verify: raise DeclError("fast / auto の昇格のプランに導入の確認のコマンド(<節>.verify)が無い") state = str(Path(mvv).resolve()) - material = "{state_dir}/work/approval-promote.md" + material = PROMOTE_MATERIAL pr_of = f'$(gh pr list --head {shlex.quote(head)} --base {shlex.quote(base)} --state open --json number --jq ".[0].number")' steps = [ facts_step(repo, verify_facts_cmd(a, repo, verify), "prepare", "verify"), diff --git a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py index 5c8db5b4e..afd0c381c 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py @@ -19,6 +19,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] sys.path.insert(0, str(SCRIPTS / "lib")) +import approved_commit # noqa: E402 from step_result import validate_result # noqa: E402 PY = sys.executable @@ -887,10 +888,10 @@ def test_merge_gate_judges_without_gh(repo, gh): def test_promote_creates_pr_and_merges_after_approval_without_cleanup(repo, gh): """I7: 昇格の Pull Request を作り、承認が無ければ止め、承認があればマージして後片付けをしない。""" declare(repo, *TRYGROUP) - gh.set(create_number=9, pr_seq={"9": [dict(to("main"), headRefName="develop")]}) + gh.set(create_number=9, pr_seq={"9": [dict(to("main", X), headRefName="develop")]}) code, out, _ = call("merged-steps.py", ["promote", "--head", "develop", "--base", "main", "--interval", "0"], gh.env, repo) assert code == 10 and "promote-approved" in out["next"] and merges(gh) == [] - gh.set(open_prs=[{"number": 9}], pr_seq={"9": [dict(to("main"), headRefName="develop")] * 3}) + gh.set(open_prs=[{"number": 9}], pr_seq={"9": [dict(to("main", X), headRefName="develop")] * 3}) code, out, err = call( "merged-steps.py", ["promote", "--head", "develop", "--base", "main", "--interval", "0", "--recheck", "0", "--gate-approved", "mvv"], @@ -906,10 +907,11 @@ def test_promote_creates_pr_and_merges_after_approval_without_cleanup(repo, gh): def test_promote_prepare_writes_material_and_stops_short(repo, gh): declare(repo, *TRYGROUP) - gh.set(create_number=9, pr_seq={"9": [dict(to("main"), headRefName="develop")]}) + gh.set(create_number=9, pr_seq={"9": [dict(to("main", X), headRefName="develop")]}) code, out, _ = call("merged-steps.py", ["promote", "--head", "develop", "--base", "main", "--prepare"], gh.env, repo) assert code == 0 and out["metrics"]["pr"] == 9 and Path(out["presentation_path"]).is_file() assert merges(gh) == [] + assert approved_commit.material_sha(out["presentation_path"]) == X # #2104 の AC6・I2 # --- #1485: 手動確認(マージ前)の行が未確認なら merge-gate が止まる --------------------------------- @@ -1007,3 +1009,89 @@ def test_promote_help_shows_the_verified_commit_argument(gh, repo): """AC9: promote の --help に照合の引数が出る。""" p = subprocess.run([PY, str(SCRIPTS / "merged-steps.py"), "promote", "--help"], capture_output=True, text=True, env=gh.env) assert p.returncode == 0 and "--expect-head-from" in p.stdout + + +# --- #2104: 昇格の承認の後のマージは、承認ゲート 2 の承認資料の承認したコミットのときだけ行う ---------------------- + + +def promote_args(*extra): + return ["promote", "--head", "develop", "--base", "main", "--interval", "0", "--recheck", "0", *extra] + + +def promote_material(tmp_path, sha=X, row="承認したコミット"): + """昇格の承認資料(gate_stop が書く形の判断の表の行)。sha が None なら資料を作らない。""" + p = tmp_path / "approval-promote.md" + if sha is not None: + p.write_text(f"## 2. 承認の判断に使うもの\n\n| 項目 | 内容 |\n| --- | --- |\n| {row} | {sha} |\n", encoding="utf-8") + return p + + +def test_promote_gate_writes_the_head_into_the_material(repo, gh, tmp_path): + """AC1・AC8・I1: 承認ゲート 2 で止まるとき、最初の読みの先端を資料・metrics・gate の items に載せ、next が資料で照合する。""" + declare(repo, *TRYGROUP) + out_path = tmp_path / "approval-promote.md" + gh.set(create_number=9, pr_seq={"9": [dict(to("main", X), headRefName="develop"), dict(to("main", Y), headRefName="develop")]}) + code, out, err = call("merged-steps.py", promote_args("--out", str(out_path)), gh.env, repo) + assert code == 10, (out, err) + assert approved_commit.material_sha(out_path) == X and out["metrics"]["approved_sha"] == X + assert next(i for i in out["items"] if i.get("result") == "gate")["head"] == X + assert f"--approved-from {out_path}" in out["next"] and merges(gh) == [] + + +def test_promote_gate_does_not_write_a_material_for_an_unreadable_head(repo, gh, tmp_path): + """I1: 先端が 40 桁の 16 進で読めなければ、資料を書かずに 2 で終える。""" + declare(repo, *TRYGROUP) + out_path = tmp_path / "approval-promote.md" + gh.set(create_number=9, pr_seq={"9": [dict(to("main", "a"), headRefName="develop")]}) + code, out, err = call("merged-steps.py", promote_args("--out", str(out_path)), gh.env, repo) + assert code == 2 and out["status"] == "stopped", (out, err) + assert not out_path.exists() and merges(gh) == [] + + +def test_promote_approved_merges_the_approved_commit(repo, gh, tmp_path): + """AC2・I3: 資料が X・先端が X のまま CI が通れば、X に固定して 1 度だけマージする。""" + declare(repo, *TRYGROUP) + gh.set(open_prs=[{"number": 9}], pr_seq={"9": [dict(to("main", X), headRefName="develop")] * 3}) + material = promote_material(tmp_path) + code, out, err = call("merged-steps.py", promote_args("--approved-from", str(material), "--gate-approved", "user"), gh.env, repo) + assert code == 0, (out, err) + assert merges(gh) == [["pr", "merge", "9", "--admin", "--merge", "--match-head-commit", X]] + + +@pytest.mark.parametrize( + "seq", + [ + [dict(to("main", Y), headRefName="develop")] * 2, # 承認の前(か後)に進んだ + [dict(to("main", X), headRefName="develop"), dict(to("main", Y), headRefName="develop")], # CI を待つ間に進んだ + ], +) +def test_promote_approved_stops_when_the_head_moved(repo, gh, tmp_path, seq): + """AC3・I3: 先端が承認したコミットと違えば、マージせずに head_moved で 1。next が承認ゲート 2 からのやり直しを示す。""" + declare(repo, *TRYGROUP) + gh.set(open_prs=[{"number": 9}], pr_seq={"9": seq}) + material = promote_material(tmp_path) + code, out, err = call("merged-steps.py", promote_args("--approved-from", str(material), "--gate-approved", "user"), gh.env, repo) + assert code == 1 and out["status"] == "stopped", (out, err) + assert {"kind": "pr", "name": "#9", "result": "head_moved", "head": Y, "expected": X} in out["items"] + assert "--from promote" in out["next"] and merges(gh) == [] + + +@pytest.mark.parametrize( + "sha, row", + [(None, "承認したコミット"), (X, "確かめたコミット"), (X[:39], "承認したコミット"), ("z" * 40, "承認したコミット")], +) +def test_promote_approved_does_not_merge_without_a_readable_approved_commit(repo, gh, tmp_path, sha, row): + """AC4・I4: 資料が無い・欄が無い・40 桁の 16 進でなければ、gh を 1 度も呼ばずに 2 で止まる。""" + declare(repo, *TRYGROUP) + material = promote_material(tmp_path, sha, row) + code, out, err = call("merged-steps.py", promote_args("--approved-from", str(material), "--gate-approved", "user"), gh.env, repo) + assert code == 2 and out["status"] == "stopped", (out, err) + assert gh.get().get("calls", []) == [] + + +def test_promote_user_approval_needs_the_material(repo, gh): + """I5: --gate-approved user を --approved-from 無しで受けず、gh を 1 度も呼ばずに 2 で止まり、資料つきの打ち方を示す。""" + declare(repo, *TRYGROUP) + code, out, err = call("merged-steps.py", promote_args("--gate-approved", "user"), gh.env, repo) + assert code == 2 and out["status"] == "stopped", (out, err) + assert "--approved-from" in out["next"] and gh.get().get("calls", []) == [] diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index a8164ed53..818ccbf40 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -123,15 +123,19 @@ def test_the_promote_merges_only_the_commit_the_mvv_judge_read(tmp_path, pace): def test_the_promote_plan_uses_the_declared_branch_names(tmp_path): - """#1529 の AC7・AC8・I5: 照合の引数は fast / auto の promote にだけ付き、ブランチ名は引数のものだけが現れる。""" + """#1529 の AC7・AC8・I5: 確かめたコミットの照合は fast / auto の promote にだけ付き、ブランチ名は引数のものだけが現れる。 + #2104 の AC5・I7: normal の promote は昇格の承認資料を書き、両方の promote-approved が同じ資料で照合する。""" from supervise_lib.delivery_templates import plan_promote a = type("A", (), {"base": "trunk", "production_branch": "stable", "issue": "11", "mode": "standard"})() normal = steps_of(plan_promote(a, str(tmp_path), 600, verify="true")) fast = steps_of(plan_promote(a, str(tmp_path), 600, mvv=str(tmp_path / "state.json"), verify="true")) base = f"{SCRIPTS / 'merged-steps.py'} promote --head trunk --base stable --timeout 600" - assert normal["promote"]["cmd"].endswith(base) and normal["promote-approved"]["cmd"].endswith(base + " --gate-approved user") + promote_material = "{state_dir}/work/approval-promote.md" + assert normal["promote"]["cmd"].endswith(base + f" --out {promote_material}") + assert normal["promote-approved"]["cmd"].endswith(base + f" --approved-from {promote_material} --gate-approved user") assert fast["promote-approved"]["cmd"] == normal["promote-approved"]["cmd"] + assert fast["prepare"]["cmd"].endswith(f"--prepare --out {promote_material}") # #2104 の AC5・I7: 書き手と読み手が同じ資料 want = base + " --expect-head-from {state_dir}/work/approval-verify.md --gate-approved mvv" assert fast["promote"]["cmd"].endswith(want) for s in (*normal.values(), *fast.values()): From a6e2d95e674c46565d84c8dc480b861b27c3eb6e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 11:47:28 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs(ndf):=20=E6=98=87=E6=A0=BC=E3=81=AE?= =?UTF-8?q?=E6=89=BF=E8=AA=8D=E3=81=AE=E5=BE=8C=E3=81=AE=E7=85=A7=E5=90=88?= =?UTF-8?q?=E3=82=92=20pace.md=20=E3=81=A8=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=AB=E6=9B=B8=E3=81=8F=EF=BC=88#2104=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf-release-approved-commit.md | 67 ++++++++++++++++--- .../development-workflow/references/pace.md | 7 +- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/docs/specifications/ndf-release-approved-commit.md b/docs/specifications/ndf-release-approved-commit.md index 2bdb94d91..888b286a7 100644 --- a/docs/specifications/ndf-release-approved-commit.md +++ b/docs/specifications/ndf-release-approved-commit.md @@ -9,6 +9,8 @@ 照会し直さなくてよい - **`pace: fast` / `auto` の昇格の経路では、本番チャネルへ入るのは MVV 判定が読んだ導入の確認の記録のコミット(確かめたコミット)に限る。** 確認の後にベースブランチが進めば、マージせずに承認ゲート 2 へ戻る(#1529。「昇格の経路の照合」) +- **承認ゲート 2 の承認の後の昇格のマージ(`promote-approved`)は、利用者が承認資料に見た昇格の Pull Request の先端(承認したコミット)に限る。** + 承認の後にベースブランチが進めば、マージせずに承認ゲート 2 へ戻る(#2104。「昇格の承認の後の照合」) 例: ndf のある版を本番へ出すとき、承認の 6 分後に別の PR がベースブランチへマージされた。 @@ -46,6 +48,7 @@ | 承認資料 | `issues/approval-<プラグイン>-v<版>.md`。`approval-facts` が書く | | 導入の確認の承認資料 | 昇格のプランの `verify` ステップ(`release-steps.py verify-facts`)が書く `approval-verify.md`。確かめたコミットの行を 1 行持つ | | 昇格の Pull Request | ベースブランチ → 本番チャネルの Pull Request を `merged-steps.py promote` が作ってマージするもの。head はベースブランチそのもの | +| 昇格の承認資料 | 昇格の承認ゲート 2 で利用者(か MVV 判定)が見る `{state_dir}/work/approval-promote.md`。`normal` では `promote` の承認ゲート 2 の停止が、`fast` / `auto` では `prepare` が書く。「承認したコミット」の行を 1 行持つ | ベースブランチ・本番チャネル・プラグイン名は宣言(`.ndf/worktree.json` の `base_branch`・`production_branch` と `.ndf/supervise.json` の `release.plugin`)と引数から読み、 `develop` / `main` を埋め込まない。 @@ -55,11 +58,12 @@ | 扱う | 扱わない | | --- | --- | | `release-steps.py approval-facts` が承認したコミットを書くこと | `--channel dev` の振る舞い(開発版はマージした変更がそのまま載るチャネルで、承認を要しない。引数も結果も変わらない) | -| `release-steps.py approve` が承認の記録を書くこと、`mvv-gate.py check --gate release` が `by: mvv` で書くこと | `pace: normal` の昇格(`promote` → 利用者の承認 → `promote-approved --gate-approved user`)の照合。導入の確認を走らせず、照合する記録が無い | +| `release-steps.py approve` が承認の記録を書くこと、`mvv-gate.py check --gate release` が `by: mvv` で書くこと | 昇格の承認資料の「承認の記録」の行(`promote-approved` は承認したコミットの行だけで照合し、承認の記録を求めない) | | `release-steps.py release --channel prod` が承認したコミットを受け取り、承認の外のコミットがあれば止まること | 承認資料のほかの欄、MVV 判定の判定の中身、導入の確認(`verify-facts`)の走らせ方 | | `merged-steps.py merge-when-green --expect-head` が比べた先端だけをマージすること | 食い違ったときに承認資料を自動で作り直して再承認を求める仕組み、導入の確認と MVV 判定を自動でやり直す仕組み(止まって人へ戻すまでを扱う) | | 本番のリリースプラン(`supervise.py new release --channel prod`。`--mvv` 付きを含む)が承認資料を渡すこと | 手動反映の本番系(`gate-2` のプラン。#1454) | -| `pace: fast` / `auto` の昇格のプラン(`plan_promote`)の `promote` ステップが、確かめたコミットのときだけ本番チャネルへマージし、違えば承認ゲート 2 へ戻ること(`merged-steps.py promote --expect-head-from`。#1529) | | +| `pace: fast` / `auto` の昇格のプラン(`plan_promote`)の `promote` ステップが、確かめたコミットのときだけ本番チャネルへマージし、違えば承認ゲート 2 へ戻ること(`merged-steps.py promote --expect-head-from`。#1529) | 経路 `merge` で宛先が自動反映の本番チャネルのときの `merge-approved`(`merge-when-green --gate-approved user`)の照合 | +| 昇格のプランの `promote-approved` が、昇格の承認資料の承認したコミットのときだけ本番チャネルへマージし、違えば承認ゲート 2 へ戻ること(`merged-steps.py promote --approved-from`。#2104) | | ## 背景 @@ -216,7 +220,7 @@ git を読むだけで書かず、終了コードも出力も持たない。ブ | P2 | `--expect-head-from` を受けた `promote` は、資料から確かめたコミットを読めないまま照合を飛ばさない | `gh` を 1 度も呼ばずに終了コード 2(`EXIT_UNREADABLE`)で止まる | | P3 | 確かめたコミットの行を書く形と読む形は `verified_line` / `verified_sha` の 1 か所が決める | 読めなければ P2 の扱い | | P4 | `fast` / `auto` の `promote` ステップは、`mvv` ステップが材料に渡すのと同じパス(`VERIFY_MATERIAL`)を `--expect-head-from` に渡し、失敗は `handoff` へ進む | プランの組み立てのテストで落とす | -| P5 | `normal` の `promote` と `promote-approved` のコマンドは `--expect-head-from` を持たない | プランの組み立てのテストで落とす | +| P5 | `--expect-head-from` は `fast` / `auto` の `promote` ステップだけが持ち、`promote-approved` は持たない(昇格の承認の後の照合は `--approved-from`) | プランの組み立てのテストで落とす | ```mermaid sequenceDiagram @@ -264,8 +268,32 @@ sequenceDiagram `promote` の失敗の種類(`head_moved` の 1・読めない 2・マージの拒否 1・衝突 4)で行き先を分けず、どれも `on_fail: handoff` が 承認ゲート 2 へ戻す。戻った後の続きは `development-workflow` の `references/pace.md` の承認ゲート 2 の行が正で、照合で止まった -(`head_moved`・資料を読めない)ときは `run <プラン> --from verify` から流し直す。`--from promote-approved` は照合を持たず、 -導入の確認も MVV 判定もしていない今の先端をマージするためである。 +(`head_moved`・資料を読めない)ときは `run <プラン> --from verify` から流し直す。`--from promote-approved` は `prepare` が書いた +昇格の承認資料の承認したコミットで照合するが、導入の確認も MVV 判定もその先端では走っていないためである。 + +### 昇格の承認の後の照合(#2104) + +`normal` の昇格のプランは `promote`(`--out {state_dir}/work/approval-promote.md`)→ 承認ゲート 2 → `promote-approved` の順に流れる。 +`promote-approved` ステップは `normal` と `fast` / `auto` で同じコマンド +`merged-steps.py promote ... --approved-from {state_dir}/work/approval-promote.md --gate-approved user` を打つ。 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | 値オブジェクト | +| --- | --- | --- | --- | +| 昇格の承認資料 | 本文と「承認したコミット」の行は `merged_lib/merge.py` の `gate_stop`(`normal`)と `_prepare_promote`(`fast` / `auto`)。「承認の記録」(`by: mvv`)の行は `fast` / `auto` の `mvv-gate.py` だけ。行の形は `lib/approved_commit.py` の `material_row` / `material_sha` | `approval-promote.md` | 承認したコミット(40 桁) | +| 昇格のプラン | `supervise_lib/delivery_templates.py` の `plan_promote` | プラン | 昇格の承認資料のパス(`PROMOTE_MATERIAL`) | + +常に成り立つ条件: + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| P6 | `promote` が昇格の Pull Request で承認ゲート 2 に止まる(終了コード 10)とき、止まる判定に使った読みの `headRefOid` が承認資料の「承認したコミット」の行・`metrics.approved_sha`・gate の `items[].head` に同じ値で載る。`--prepare` の資料にも同じ形の行が載る | 先端が 40 桁の 16 進で読めなければ資料を書かずに終了コード 2 | +| P7 | `--approved-from` を受けた `promote` は、先端が資料の承認したコミットのときだけ `--match-head-commit` でマージする | 違えば `gh pr merge` を呼ばずに `head_moved`(`head`・`expected`)で 1。`next` に承認ゲート 2 からのやり直しを書く | +| P8 | `--approved-from` の資料を読めないまま照合を飛ばさない。`--gate-approved user` は `--approved-from` 無しで受けない | `gh` を 1 度も呼ばずに終了コード 2。資料が無いときは `next` に資料つきの打ち方を書く | +| P9 | `normal` の `promote` の `--out`、両方の `promote-approved` の `--approved-from`、`fast` / `auto` の `prepare` の `--out` は同じ `PROMOTE_MATERIAL` である | プランの組み立てのテストで落とす | + +照合で止まったときの続きは `pace.md` の承認ゲート 2 の行が正で、`normal` は `run <プラン> --from promote` から流し直して +新しい先端で承認ゲート 2 を取り直し、`fast` / `auto` は `--from verify` から流し直す。`promote-approved` は自分の `pace` を +知らないため、`next` には両方の打ち方を並べる。 ## データ・設定 @@ -310,7 +338,7 @@ sequenceDiagram ### `merged-steps.py promote --expect-head-from <導入の確認の承認資料>` -`merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--gate-approved user|mvv] [--expect-head-from <資料>]`。 +`merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--out <資料>] [--gate-approved user|mvv] [--expect-head-from <資料> | --approved-from <資料>]`。 `--expect-head-from` は任意で、渡すと最初に `verified_sha` で確かめたコミットを読み、`merge-when-green --expect-head` と同じ照合に使う。 | 場合 | 終了コード | 結果 | @@ -320,8 +348,19 @@ sequenceDiagram | 最後の読みとマージの間に先端が変わった | 1 | `--match-head-commit X` で GitHub がマージを拒み、既存の「`gh pr merge --admin` が失敗」になる | | 資料が無い・確かめたコミットの行が無いか 2 つ以上・40 桁の 16 進でない | 2 | `status: stopped`。昇格の Pull Request を探さず・作らず、`gh` を呼ばない | -引数を渡さない呼び出し(`normal` の `promote` / `promote-approved`、手で打つ既存の呼び出し)は変わらない。`--prepare` と一緒に -渡したときも先に読み(読めなければ 2)、読めればマージへ進まない今の振る舞いのまま終える。 +`--prepare` と一緒に渡したときも先に読み(読めなければ 2)、読めればマージへ進まない今の振る舞いのまま終える。 + +### `merged-steps.py promote --approved-from <昇格の承認資料>` + +`--approved-from` は `--expect-head-from` と同時には受けない。渡すと最初に `material_sha` で承認したコミットを読み、同じ照合に使う。 +`--gate-approved user` はこの引数が要る。`--out` は `--prepare` と承認ゲート 2 の停止で書く昇格の承認資料の置き場である。 + +| 場合 | 終了コード | 結果 | +| --- | --- | --- | +| 承認ゲート 2 で止まった(`--gate-approved` 無し) | 10 | `metrics.approved_sha` と gate の `items[].head` に先端。`next` は `promote ... --gate-approved user --approved-from <書いた資料>`(プランなら `--from promote-approved`) | +| 先端が承認したコミットのまま CI が通った | 0 | `items[]` に `{"kind": "pr", "result": "merged", "head": , "gate_approved": "user"}` | +| 先端が承認したコミットでない | 1 | `items[]` に `head_moved`(`head`・`expected`)。`next` に `--from promote`(`fast` / `auto` は `--from verify`)から流し直すこと | +| 資料が無い・欄が無い・40 桁の 16 進でない、`--gate-approved user` に `--approved-from` が無い | 2 | `status: stopped`。`gh` を呼ばない | ### 本番のリリースプラン @@ -347,6 +386,10 @@ sequenceDiagram | `promote` は SHA ではなく承認資料のパスを受けて自分で読む(#1529) | プランのコマンドで `--expect-head "$(…)"` と SHA を置換して渡すと、読めなかったときに空の値が渡り、`_check_expected` は空を「照合しない」と扱う。パスを受けて中で読めば、読めないことを `promote` 自身が失敗として返せる(P2)。SHA を出す副命令を足してプランで繋ぐ形は、ステップが増え空の値の経路も残る | | 確かめたコミットの行の形を `lib/approved_commit.py` に置き、`verify_markdown` もそれで書く(#1529) | 読み手だけを足すと、書き手の文言を直したときに読み手が黙って読めなくなる。承認資料に `\| 承認したコミット \|` の欄を足して `material_sha` で読む形は、`mvv-gate.py` がその欄を見て承認の記録を書き足すようになり、承認資料の中身を変える | | プランは `mvv` と同じ定数 `VERIFY_MATERIAL` を `promote` に渡す(#1529) | MVV 判定が読んだ資料と照合に使う資料が同じファイルになる。資料を書くのは `verify` ステップだけで、`--from verify` で打ち直すと資料も作り直される。プランを組む時点の先端や `origin/<ベースブランチ>` を引き直すと、確認の後に進んだ先端を拾い照合の意味が無くなる | +| 昇格の承認資料に「承認したコミット」の行を置き、`material_row` / `material_sha` で読み書きする(#2104) | 昇格の Pull Request の先端はベースブランチの先端で、本番の配布の承認資料と同じ語の意味が当たる。`verified_line` を流用すると、導入の確認を走らせない `normal` で「導入の確認をこのコミットで走らせた」と書くことになる | +| 照合の基準は承認したコミットの行だけにし、承認の記録を求めない(#2104) | 承認ゲート 2 の承認は `sprint-state.py gate ... --by user` で既に残る。`approve` まで求めると昇格の経路だけ承認の後の操作が 1 つ増える。防ぐのは利用者が見た先端と違う先端のマージで、資料の SHA との一致で足りる | +| `--gate-approved user` は `--approved-from` 無しで受けない(#2104) | 版の更新の前に書き出したプランの `promote-approved` を照合なしで通すと、照合を飛ばす経路が残る。止めても `--from promote` で流し直せば戻せる | +| 照合で止まった後を `on_fail` で自動に戻さない(#2104) | `on_fail` は CI の失敗やマージの拒否にも掛かり、承認ゲート 2 の取り直しに読み替えてしまう。続きは `next` と `pace.md` が示す | | `promote` の失敗の種類で行き先を分けない(#1529) | どれも `on_fail: handoff` が承認ゲート 2 へ戻す。`on_exit` で種類ごとに行き先を変えると、戻った後の人の手順が種類ごとに分かれる。代わりに人が選ぶ続き(`--from verify`)を `pace.md` に書く | ## 既知の制約 @@ -354,8 +397,9 @@ sequenceDiagram | 項目 | 内容 | | --- | --- | | 配布の PR のコミット数 | `gh pr view --json commits` は 250 件までを返す。超える配布の PR では I3 が外のコミットと誤り、止まる側に倒れる。配布の PR は bump・changelog・説明文・消費の記録の数件である | -| `normal` の昇格の照合 | 利用者が承認ゲート 2 で見た先端と `promote-approved` がマージする先端は照合しない。同じ食い違いは起きうるが、照合の材料(承認したときの先端を残す記録)が `normal` の経路に無い | -| 照合で止まった後の続き | 人が `--from promote-approved` を選ぶと、照合を持たない `promote-approved` が確かめていない先端をマージする。防ぐのは `pace.md` の続きの行だけで、`handoff` の文言(「昇格のマージ」)も先端が動いたことを示さない。人は `promote` の出力の `head_moved` を見て選ぶ | +| 照合で止まった後の続き(`fast` / `auto`) | 人が `--from promote-approved` を選ぶと、`prepare` が書いた承認したコミットのままなら、導入の確認と MVV 判定の後に進んでいない先端としてマージされる。`promote` が `head_moved` で止まった後は承認資料の先端と昇格の Pull Request の先端も違うため、`promote-approved` も止まる | +| 書き出し済みのプラン | 版の更新の前に書き出したプランの `promote-approved` は `--approved-from` を持たず 2 で止まる。`--from promote` で流し直すと、書き出し済みの `promote` は `--out` を持たないため資料は既定の置き場へ書かれ、`next` の手で打つ形で続ける | +| 経路 `merge` の `merge-approved` | 宛先が自動反映の本番チャネルのとき、`merge-when-green` の承認ゲート 2 の停止も先端を資料へ残さず、`--from merge-approved` は今の先端をマージする(この文書の範囲外) | | 実機での昇格 | 本番チャネルへのマージを伴うため、手元では `gh` を差し替えたテストでだけ確かめた。実機はリリース後テストで、経路 `promote` を持つリポジトリが `fast` / `auto` で通るときに見る | | rebase で入れた配布の PR の `mergeCommit` | 実物で確かめていない。`mergeCommit` は承認の外のコミットの一覧から除くのにだけ使い、木の比較には使わない。違っていても、patch-id で除けないものが一覧に余分に並ぶだけで、通す・止めるの向きは止まる側である | @@ -381,7 +425,8 @@ sequenceDiagram | 昇格: 確かめたコミットならマージする(P1) | 先端が資料の X のまま CI が通ると、`promote --expect-head-from` が `gh pr merge … --match-head-commit X` を 1 度呼んで 0 で終わること | | 昇格: 先端が進んだらマージしない(P1) | 最初の読みで先端が Y なら `gh pr merge` を呼ばずに `head_moved`(`head: Y`・`expected: X`)と終了コード 1。1 回目の読みは X・2 回目で Y でも同じ。待つ間に Y で既にマージされた PR も成功にしないこと | | 昇格: 資料を読めなければ何もしない(P2) | 資料が無い・行が無い・行が 2 つ・SHA が 39 桁のそれぞれで、`gh` を 1 度も呼ばずに終了コード 2 になること | -| 昇格のプラン(P4・P5) | `fast` / `auto` の `promote` ステップが `--expect-head-from` と `mvv` の `--material` に同じ `VERIFY_MATERIAL` を持ち、`on_fail` が `handoff` で、非 0 で `handoff` へ進み終了コード 10 になること。`normal` の `promote` と `promote-approved` が `--expect-head-from` を含まないこと。`base` と `production_branch` を `develop` / `main` 以外で組むとその名前だけが現れること(`test_supervise_pace_verify.py`・`test_supervise_new.py`) | +| 昇格のプラン(P4・P5・P9) | `fast` / `auto` の `promote` ステップが `--expect-head-from` と `mvv` の `--material` に同じ `VERIFY_MATERIAL` を持ち、`on_fail` が `handoff` で、非 0 で `handoff` へ進み終了コード 10 になること。`promote-approved` が `--expect-head-from` を含まず、`normal` と `fast` / `auto` で等しく `--approved-from PROMOTE_MATERIAL` を持ち、`normal` の `promote` と `fast` / `auto` の `prepare` の `--out` が同じパスであること。`base` と `production_branch` を `develop` / `main` 以外で組むとその名前だけが現れること(`test_supervise_pace_verify.py`・`test_supervise_new.py`) | +| 昇格の承認の後の照合(P6〜P8) | 承認ゲート 2 の停止で資料・`metrics.approved_sha`・gate の `items[].head` が最初の読みの先端に等しく、先端が読めなければ資料を書かずに 2 であること。`--prepare` の資料も同じ行を持つこと。`--approved-from` の資料が X で先端が X なら `--match-head-commit X` で 1 度マージし、Y なら(最初の読みでも待つ間でも)マージせずに `head_moved` と `--from promote` を含む `next` で 1 であること。資料が無い・欄が無い・形が違う・`--gate-approved user` に資料が無いときは `gh` を呼ばずに 2 であること(`test_sprint_close_merge_green.py`) | | 書き手と読み手(P3) | `verify_markdown` の出力を `verified_sha` で読むと書いた SHA が返り、出力の文字列が変わらないこと。`merged-steps.py promote --help` が `--expect-head-from` を出すこと(`test_approved_commit.py`・`test_sprint_close_merge_green.py`) | ## 関連リンク diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index 806394f96..89f4438cc 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -232,7 +232,10 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの `approval-verify.md` を `--expect-head-from` に受け、そこに書かれた確かめたコミット(導入の確認を走らせたベースブランチの先頭)が 昇格の Pull Request の先端のときだけマージする。確認の後にベースブランチが進んで先端が違えば(CI を待つ間に進んだときも) `gh pr merge` を呼ばずに `head_moved` で 1、資料から確かめたコミットを読めなければマージせずに 2 で止まり、どちらも `handoff` が -承認ゲート 2 の `by: mvv` の記録を外して承認ゲートへ落とす。`fast` / `auto` の `merge` の経路では開発版のステージを置かない +承認ゲート 2 の `by: mvv` の記録を外して承認ゲートへ落とす。`normal` の `promote` は承認ゲート 2 で止まるときに昇格の +Pull Request の先端を「承認したコミット」として昇格の承認資料(`approval-promote.md`。`fast` / `auto` では `prepare` が書く)へ載せ、 +`promote-approved` は `pace` によらずその資料の承認したコミットが先端のときだけマージする。先端が違えば `head_moved` で 1、 +資料から読めなければ 2 で、マージせずに止まる。`fast` / `auto` の `merge` の経路では開発版のステージを置かない (ベースブランチへのマージが検証への反映である)。 **`fast` / `auto` の `merge` の経路では、導入の確認(`<節>.verify`)を 1 つのスプリントの中で 1 度だけ走らせる。** 昇格のプランが @@ -264,7 +267,7 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの | 答え | 打つもの | | --- | --- | | 承認(ゲート 1) | `sprint-state.py gate <状態> "関門 1" --what <要約> --by user --outcome approved` → `supervise.py run <設計のプラン> --from approve` → マニフェストのスプリントブランチのステージの `resume` | -| 承認(ゲート 2) | `sprint-state.py gate <状態> "関門 2" --what <要約> --by user --outcome approved` → `supervise.py run <本番のプラン> --from bump`(昇格のプランは `--from promote-approved`。ただし `promote` が照合で止まった(`head_moved`・確かめたコミットを読めない)ときは `--from verify` から流し直す。`promote-approved` は照合を持たず、導入の確認も MVV 判定もしていない今の先端をマージする。マージの `merge-gate` で止まったプランは `--from merge-approved`。`gate-2` のプランは続きを流さず、手で行う「本番」のステージへ進む) | +| 承認(ゲート 2) | `sprint-state.py gate <状態> "関門 2" --what <要約> --by user --outcome approved` → `supervise.py run <本番のプラン> --from bump`(昇格のプランは `--from promote-approved`。ただし `promote` が照合で止まった(`head_moved`・確かめたコミットを読めない)ときは `--from verify` から流し直す。`promote-approved` が照合で止まった(`head_moved`・承認したコミットを読めない)ときは、`normal` は `--from promote` から流し直して新しい先端で承認ゲート 2 を取り直し、`fast` / `auto` は `--from verify` から流し直す。マージの `merge-gate` で止まったプランは `--from merge-approved`。`gate-2` のプランは続きを流さず、手で行う「本番」のステージへ進む) | | 差し戻し | `sprint-state.py gate <状態> "関門 N" --what <要約> --by user --outcome rejected`。続きは流さない。差し戻しは状態ファイルでなく差し戻しの記録(`gate-rejections.jsonl`)へ 1 行残る。状態ファイルの無い進め方の打ち方は [approval-request.md](approval-request.md) の「答えを記録する」 | **判定と食い違う答えは覆しとして残る。** 「従う」で自動に通った後の差し戻しは `override_reject`、「従わない」「判定できない」で From 2101affcbc6db54f8fd7e535cf3fd82bab3a1ed8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 11:58:09 +0000 Subject: [PATCH 3/5] =?UTF-8?q?refactor(ndf):=20promote=20=E3=81=AE?= =?UTF-8?q?=E7=94=A8=E6=84=8F=E3=81=A8=E7=85=A7=E5=90=88=E3=81=AE=E5=BC=95?= =?UTF-8?q?=E6=95=B0=E3=82=92=20merged=5Flib/merge.py=20=E3=81=B8=E5=AF=84?= =?UTF-8?q?=E3=81=9B=E3=80=81merged-steps.py=20=E3=82=92=20500=20=E8=A1=8C?= =?UTF-8?q?=E3=81=AB=E5=8F=8E=E3=82=81=E3=82=8B=EF=BC=88#2104=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/merged-steps.py | 17 +++-------------- plugins/ndf/scripts/merged_lib/merge.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/plugins/ndf/scripts/merged-steps.py b/plugins/ndf/scripts/merged-steps.py index a5f5719b4..63578ebcc 100755 --- a/plugins/ndf/scripts/merged-steps.py +++ b/plugins/ndf/scripts/merged-steps.py @@ -6,8 +6,7 @@ python3 merged-steps.py merge-gate (--base <宛先> | --pr ) [--pr ] [--root ] python3 merged-steps.py merge-when-green [--gate-approved user|mvv] [--method merge|squash|rebase] [--interval 秒] [--timeout 秒] [--stale-after 秒] [--no-cleanup] [--expect-head ] [--root ] - python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--out <資料>] [--gate-approved user|mvv] - [--expect-head-from <資料> | --approved-from <資料>] + python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--out <資料>] [--gate-approved user|mvv] [--expect-head-from <資料> | --approved-from <資料>] cleanup: マージ済みの PR の作業ツリーとローカルブランチを外し、主ディレクトリを取り込む。 `git worktree remove` が拒否した作業ツリーは、未追跡・無視されたファイルを退避してから外す。作り直せる生成物 @@ -21,9 +20,7 @@ CI を待たずに承認ゲート 2 で止まる(status: gate・metrics.gate: production-merge。判定は lib/delivery.py。#1336)。 merge-gate: 宛先の判定だけを行う(0 = 進めてよい / 10 = 承認ゲート 2)。 promote: 昇格の Pull Request(ベースブランチ → 本番チャネル)を探すか作り、merge-when-green と同じ判定と待ちでマージする(後片付けはしない)。 ---prepare は用意して承認資料を書くところで 0 で終える。--expect-head-from は導入の確認の承認資料の確かめたコミットを --expect-head と同じ照合に使う(違えば head_moved で 1、読めなければ 2。#1529)。 -承認ゲート 2 で止まるときは、昇格の Pull Request の先端を「承認したコミット」として承認資料(--out)と metrics.approved_sha に載せる。 ---approved-from はその承認資料の承認したコミットを同じ照合に使う。--gate-approved user は --approved-from が無ければ 2 で止まる(#2104)。 +--prepare は用意して承認資料を書くところで 0 で終える。--expect-head-from は導入の確認の承認資料の確かめたコミットを --expect-head と同じ照合に使う(違えば head_moved で 1、読めなければ 2。#1529)。承認ゲート 2 で止まるときは先端を「承認したコミット」として承認資料(--out)と metrics.approved_sha に載せ、--approved-from はその資料の承認したコミットを同じ照合に使う(--gate-approved user に要る。#2104)。 実行が終わったのにチェックが pending のまま --stale-after 秒続けば、そのジョブを 1 度だけ `gh run rerun --job` で再実行し、 再実行でも取り残されれば止まる。実行が終わりジョブに結論があれば、チェックの表示が pending のままでも待たずにその結論で扱う。 ジョブがランナーを待つ間は、待ち行列の件数を待ちの 1 周ごとに stderr へ 1 行出す。同じチェック(workflow の名前と名前の組)は最新の項目だけで数え、古い項目の失敗は items に result: superseded で残す。 @@ -479,15 +476,7 @@ def build_parser(): ) pm.add_argument("--head", required=True, help="昇格させるブランチ(ベースブランチ)") pm.add_argument("--base", required=True, help="本番チャネル") - pm.add_argument("--prepare", action="store_true", help="Pull Request を用意して承認資料を書くところで終える(マージしない)") - pm.add_argument("--out", help="--prepare と承認ゲート 2 の停止で書く昇格の承認資料の置き場(既定は提示物の置き場)") - pin = pm.add_mutually_exclusive_group() - pin.add_argument("--expect-head-from", metavar="資料", help="導入の確認の承認資料。確かめたコミットが PR の先端のときだけマージする") - pin.add_argument( - "--approved-from", - metavar="資料", - help="昇格の承認資料。承認したコミットが PR の先端のときだけマージする(--gate-approved user に要る)", - ) + merge.add_promote_args(pm) merge.add_wait_args(pm) pm.set_defaults(func=cmd_promote) pr = sub.add_parser( diff --git a/plugins/ndf/scripts/merged_lib/merge.py b/plugins/ndf/scripts/merged_lib/merge.py index 8254d966d..c88236a07 100644 --- a/plugins/ndf/scripts/merged_lib/merge.py +++ b/plugins/ndf/scripts/merged_lib/merge.py @@ -52,6 +52,19 @@ def add_wait_args(m): ) +def add_promote_args(pm): + """promote だけが受ける、用意と照合の引数(#1529・#2104)。""" + pm.add_argument("--prepare", action="store_true", help="Pull Request を用意して承認資料を書くところで終える(マージしない)") + pm.add_argument("--out", help="--prepare と承認ゲート 2 の停止で書く昇格の承認資料の置き場(既定は提示物の置き場)") + pin = pm.add_mutually_exclusive_group() + pin.add_argument("--expect-head-from", metavar="資料", help="導入の確認の承認資料。確かめたコミットが PR の先端のときだけマージする") + pin.add_argument( + "--approved-from", + metavar="資料", + help="昇格の承認資料。承認したコミットが PR の先端のときだけマージする(--gate-approved user に要る)", + ) + + # --- 承認ゲート 2(自動反映の本番チャネルへのマージ。#1336) ------------------------ GATE = "production-merge" From 33ecaf9c99088b27be3850fa987303fb26f2a596 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 12:19:57 +0000 Subject: [PATCH 4/5] =?UTF-8?q?docs(specs):=20=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=AE=E7=B4=A2=E5=BC=95=E3=81=AB=E6=98=87=E6=A0=BC?= =?UTF-8?q?=E3=81=AE=E6=89=BF=E8=AA=8D=E3=81=AE=E5=BE=8C=E3=81=AE=E7=85=A7?= =?UTF-8?q?=E5=90=88=EF=BC=88--approved-from=EF=BC=89=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=99=EF=BC=88#2104=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specifications/README.md b/docs/specifications/README.md index 4174c8561..32e71635c 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -53,7 +53,7 @@ | [project-decl-ci-representative-run.md](project-decl-ci-representative-run.md) | 宣言の解析が CI の所要に採る代表の run(重いジョブを飛ばした run を採らず、テストを走らせた run から CI 待ちとテストの上限を出すこと・解析器の版 2 と再解析)、常に成り立つ条件と部品の契約、決定の理由。手順は `development-workflow` の `references/project-analysis.md` が正 | | [cross-assignee-reassignment-on-no-result.md](cross-assignee-reassignment-on-no-result.md) | cross-review / cross-refactoring で結果を残さなかった担当を、利用上限か 2 度目の結果なしで別の claude のアカウントか残りの参加者へ振り替えること(`assignment`・`assignee_env`・`reassign`・`assignee_launch`)、常に成り立つ条件と部品の契約、決定の理由。規則の表と手順は `cross-refactoring`・`cross-review` の SKILL.md と `docs/`、`plugins/ndf/scripts/lib/README.md` が正 | | [ndf-google-workspace-gws.md](ndf-google-workspace-gws.md) | Google Workspace の操作の gws への一本化(`gws-check.py` の 4 つの状態と終了コード・失敗の形・導入の承認資料、常に成り立つ条件、`google-auth` / `google-drive` と playwright-kit の Drive への保管の消えた約束、決定の理由)。手順と操作の対応表は `google-workspace` の SKILL.md が正 | -| [ndf-release-approved-commit.md](ndf-release-approved-commit.md) | 本番への配布の承認したコミット(承認資料に控えるベースブランチの先端・承認の記録・本番チャネルへ入れる直前の比較の順序・承認ゲートで止まる時点と結果 JSON・`merge-when-green --expect-head`)と、`pace: fast` / `auto` の昇格が導入の確認の確かめたコミットのときだけ本番チャネルへマージし違えば承認ゲート 2 へ戻ること(`merged-steps.py promote --expect-head-from`)。手順は `release` の SKILL.md と `references/release-steps.md` が正 | +| [ndf-release-approved-commit.md](ndf-release-approved-commit.md) | 本番への配布の承認したコミット(承認資料に控えるベースブランチの先端・承認の記録・本番チャネルへ入れる直前の比較の順序・承認ゲートで止まる時点と結果 JSON・`merge-when-green --expect-head`)と、`pace: fast` / `auto` の昇格が導入の確認の確かめたコミットのときだけ本番チャネルへマージし違えば承認ゲート 2 へ戻ること(`merged-steps.py promote --expect-head-from`)、昇格の承認ゲート 2 の承認の後のマージ(`promote-approved`)が昇格の承認資料の承認したコミットのときだけ本番チャネルへマージすること(`merged-steps.py promote --approved-from`)。手順は `release` の SKILL.md と `references/release-steps.md` が正 | | [ndf-production-release-record.md](ndf-production-release-record.md) | 本番のリリースプランが本番の配布の後に本番のリリースの PR へ書くリリース記録(`release-steps.py record` の前提・結果 JSON・終了コード、記録の値の決め方)、常に成り立つ条件、記録の失敗を `judge-record` へ回し配布へ戻らない流れ、報告の Pull Request を埋める `pr_from`、`pace: normal` の `new sprint` が承認ゲート 2 と本番のステージまで書き出すこと、決定の理由。記録の形とスプリントを閉じる手順は `progress-tracking` の SKILL.md、ステップの並びは `release` の `references/form-package-plugin.md` が正 | | [ndf-release-other-plugin-levels-and-migration.md](ndf-release-other-plugin-levels-and-migration.md) | 他のプラグインの上げ幅の候補(互換なしの印と移行の手順から MAJOR / PATCH を字面で決める規則)、承認資料の「版を上げる他のプラグイン」の表と `--decided` の確かめ(常に成り立つ条件・途中からの再開)、PR 本文の「移行の手順」の差し替えと CHANGELOG の `### 移行の手順` への写し、決定の理由。手順は `release` の `references/form-package-plugin.md`・`references/release-steps.md` と `pr` の SKILL.md が正 | | [ci-required-checks-reconcile.md](ci-required-checks-reconcile.md) | ワークフローのジョブと ruleset の必須のチェックの突き合わせ(`scripts/check-required-checks.py` の入出力・終了コード 0/1/2・差の種類、チェックの名前を matrix から決める規則、宣言 `scripts/required-checks-allow.json` の形、共有の関数 `ci_workflows.required_contexts`)、常に成り立つ条件、承認ゲート 2 で ruleset へ足す手順、決定の理由。承認資料の形は `development-workflow` の `references/approval-request.md` が正 | From 7b4cc37acd13e6d6cf48a66b04e03af164605432 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 12:32:20 +0000 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=E6=98=87=E6=A0=BC=E3=81=AE?= =?UTF-8?q?=E7=85=A7=E5=90=88=E3=81=AE=E6=96=87=E6=9B=B8=E3=81=8B=E3=82=89?= =?UTF-8?q?=E8=AA=B2=E9=A1=8C=E7=95=AA=E5=8F=B7=E3=81=A8=E6=AF=94=E8=BC=83?= =?UTF-8?q?=E3=81=AE=E8=AA=9E=E3=82=92=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-release-approved-commit.md | 10 +++++----- .../ndf/skills/development-workflow/references/pace.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/specifications/ndf-release-approved-commit.md b/docs/specifications/ndf-release-approved-commit.md index 888b286a7..46c5dce77 100644 --- a/docs/specifications/ndf-release-approved-commit.md +++ b/docs/specifications/ndf-release-approved-commit.md @@ -271,7 +271,7 @@ sequenceDiagram (`head_moved`・資料を読めない)ときは `run <プラン> --from verify` から流し直す。`--from promote-approved` は `prepare` が書いた 昇格の承認資料の承認したコミットで照合するが、導入の確認も MVV 判定もその先端では走っていないためである。 -### 昇格の承認の後の照合(#2104) +### 昇格の承認の後の照合 `normal` の昇格のプランは `promote`(`--out {state_dir}/work/approval-promote.md`)→ 承認ゲート 2 → `promote-approved` の順に流れる。 `promote-approved` ステップは `normal` と `fast` / `auto` で同じコマンド @@ -386,10 +386,10 @@ sequenceDiagram | `promote` は SHA ではなく承認資料のパスを受けて自分で読む(#1529) | プランのコマンドで `--expect-head "$(…)"` と SHA を置換して渡すと、読めなかったときに空の値が渡り、`_check_expected` は空を「照合しない」と扱う。パスを受けて中で読めば、読めないことを `promote` 自身が失敗として返せる(P2)。SHA を出す副命令を足してプランで繋ぐ形は、ステップが増え空の値の経路も残る | | 確かめたコミットの行の形を `lib/approved_commit.py` に置き、`verify_markdown` もそれで書く(#1529) | 読み手だけを足すと、書き手の文言を直したときに読み手が黙って読めなくなる。承認資料に `\| 承認したコミット \|` の欄を足して `material_sha` で読む形は、`mvv-gate.py` がその欄を見て承認の記録を書き足すようになり、承認資料の中身を変える | | プランは `mvv` と同じ定数 `VERIFY_MATERIAL` を `promote` に渡す(#1529) | MVV 判定が読んだ資料と照合に使う資料が同じファイルになる。資料を書くのは `verify` ステップだけで、`--from verify` で打ち直すと資料も作り直される。プランを組む時点の先端や `origin/<ベースブランチ>` を引き直すと、確認の後に進んだ先端を拾い照合の意味が無くなる | -| 昇格の承認資料に「承認したコミット」の行を置き、`material_row` / `material_sha` で読み書きする(#2104) | 昇格の Pull Request の先端はベースブランチの先端で、本番の配布の承認資料と同じ語の意味が当たる。`verified_line` を流用すると、導入の確認を走らせない `normal` で「導入の確認をこのコミットで走らせた」と書くことになる | -| 照合の基準は承認したコミットの行だけにし、承認の記録を求めない(#2104) | 承認ゲート 2 の承認は `sprint-state.py gate ... --by user` で既に残る。`approve` まで求めると昇格の経路だけ承認の後の操作が 1 つ増える。防ぐのは利用者が見た先端と違う先端のマージで、資料の SHA との一致で足りる | -| `--gate-approved user` は `--approved-from` 無しで受けない(#2104) | 版の更新の前に書き出したプランの `promote-approved` を照合なしで通すと、照合を飛ばす経路が残る。止めても `--from promote` で流し直せば戻せる | -| 照合で止まった後を `on_fail` で自動に戻さない(#2104) | `on_fail` は CI の失敗やマージの拒否にも掛かり、承認ゲート 2 の取り直しに読み替えてしまう。続きは `next` と `pace.md` が示す | +| 昇格の承認資料に「承認したコミット」の行を置き、`material_row` / `material_sha` で読み書きする | 昇格の Pull Request の先端はベースブランチの先端で、本番の配布の承認資料と同じ語の意味が当たる。`verified_line` を流用すると、導入の確認を走らせない `normal` で「導入の確認をこのコミットで走らせた」と書くことになる | +| 照合の基準は承認したコミットの行だけにし、承認の記録を求めない | 承認ゲート 2 の承認は `sprint-state.py gate ... --by user` で既に残る。`approve` まで求めると昇格の経路だけ承認の後の操作が 1 つ増える。防ぐのは利用者が見た先端と違う先端のマージで、資料の SHA との一致で足りる | +| `--gate-approved user` は `--approved-from` 無しで受けない | 版の更新の前に書き出したプランの `promote-approved` を照合なしで通すと、照合を飛ばす経路が残る。止めても `--from promote` で流し直せば戻せる | +| 照合で止まった後を `on_fail` で自動に戻さない | `on_fail` は CI の失敗やマージの拒否にも掛かり、承認ゲート 2 の取り直しに読み替えてしまう。続きは `next` と `pace.md` が示す | | `promote` の失敗の種類で行き先を分けない(#1529) | どれも `on_fail: handoff` が承認ゲート 2 へ戻す。`on_exit` で種類ごとに行き先を変えると、戻った後の人の手順が種類ごとに分かれる。代わりに人が選ぶ続き(`--from verify`)を `pace.md` に書く | ## 既知の制約 diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index 89f4438cc..3adf3a4cc 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -234,7 +234,7 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの `gh pr merge` を呼ばずに `head_moved` で 1、資料から確かめたコミットを読めなければマージせずに 2 で止まり、どちらも `handoff` が 承認ゲート 2 の `by: mvv` の記録を外して承認ゲートへ落とす。`normal` の `promote` は承認ゲート 2 で止まるときに昇格の Pull Request の先端を「承認したコミット」として昇格の承認資料(`approval-promote.md`。`fast` / `auto` では `prepare` が書く)へ載せ、 -`promote-approved` は `pace` によらずその資料の承認したコミットが先端のときだけマージする。先端が違えば `head_moved` で 1、 +`promote-approved` は `pace` のどれでもその資料の承認したコミットが先端のときだけマージする。先端が違えば `head_moved` で 1、 資料から読めなければ 2 で、マージせずに止まる。`fast` / `auto` の `merge` の経路では開発版のステージを置かない (ベースブランチへのマージが検証への反映である)。