From 97f4a921d05d4c365b1e4b09b7643ab170abf95a Mon Sep 17 00:00:00 2001 From: Yusuke Watanabe Date: Wed, 22 Jul 2026 17:29:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(auto-merge):=20dispatch=20develop's=20p?= =?UTF-8?q?ost-merge=20gates=20=E2=80=94=20a=20github.token=20merge=20fire?= =?UTF-8?q?s=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub suppresses workflow triggers for pushes made with the default github.token (recursive-run protection), and the org auto-merge sweep merges with exactly that token. Every commit it lands therefore arrives on develop with ZERO check runs. Measured across scitex-ai on 2026-07-22 (74 repos enumerated): 58 repos carry at least one bot-merged develop head whose GET /commits//check-runs returns total_count 0, while user-pushed neighbours on the same branch carry 5-19 each. Runners online and idle. This is worse than a red build: a develop-health gate reads 'no checks present' as 'no red signal' and keeps merging. Green-by-absence — the hole conceals itself. workflow_dispatch is exempt from the suppression, so after a successful merge the sweep now dispatches develop's gates explicitly: - guarded on merges > 0, after the merge loop (once per call, not per PR) - skipped on dry-run (new dry_run input) - permissions.actions: write - ::error:: + exit 1 on any dispatch failure, and on an EMPTY gate set - gates auto-discovered from the DEVELOP ref (post_merge_gates overrides) Gate discovery is per-repo because leaf repos genuinely differ in workflow filenames; a hardcoded org list would 404 on half the org. Pinned by tests/test_auto_merge_dispatch.py (13 tests, file-only, no network) run by the new self-test.yml. Mutation-proven: --ref main -> 2 red; discovery ref=main -> 1 red; dispatch line deleted -> 3 red; actions:write dropped -> 1 red; restored -> 13 pass. Reference: scitex-ai/scitex-agent-container#809 --- .github/workflows/auto-merge-to-develop.yml | 135 +++++++++- .github/workflows/self-test.yml | 30 +++ ...erge_dispatch.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 17113 bytes tests/test_auto_merge_dispatch.py | 242 ++++++++++++++++++ 4 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/self-test.yml create mode 100644 tests/__pycache__/test_auto_merge_dispatch.cpython-312-pytest-9.1.1.pyc create mode 100755 tests/test_auto_merge_dispatch.py diff --git a/.github/workflows/auto-merge-to-develop.yml b/.github/workflows/auto-merge-to-develop.yml index 3179443..73ad91b 100644 --- a/.github/workflows/auto-merge-to-develop.yml +++ b/.github/workflows/auto-merge-to-develop.yml @@ -9,13 +9,65 @@ name: auto-merge-to-develop # check_suite-triggered workflows from the DEFAULT branch, so the CALLER # stub in each leaf repo must live on that repo's default branch (main) — # same requirement the original file had. +# +# *** A github.token MERGE FIRES NOTHING ON develop. *** GitHub suppresses +# workflow triggers for pushes made with the default workflow token +# (recursive-run protection), and `gh pr merge` below pushes the merge commit +# with exactly that token — so every commit this sweep lands arrives on +# develop with ZERO check runs. Measured across the scitex-ai org +# (2026-07-22, 74 repos enumerated): 58 repos carry at least one bot-merged +# develop head whose `GET /commits//check-runs` returns +# `total_count: 0` while their user-pushed neighbours on the same branch +# carry 5-19 each. Examples: figrecipe 3ebb01c0 -> 0 vs b33a8ed3 -> 19; +# scitex-io 49d709be -> 0 (4 of 4 sampled bot heads at 0) vs f9fe3dd5 -> 8; +# scitex-app 0e69dfd5 -> 0 (5 of 5) vs 827b75c9 -> 16. Runners were online +# and idle — not a capacity problem. +# +# WHY THAT IS WORSE THAN A RED BUILD: a downstream develop-health gate reads +# "no checks present" as "no red signal to honour" and keeps merging. +# Green-by-absence — the hole conceals itself, so a repo can sit in this +# state for months and look healthy. +# +# `workflow_dispatch` IS exempt from the suppression, so after a successful +# merge this workflow explicitly DISPATCHES develop's post-merge gates — +# ONCE per call, not once per merged PR, skipped on dry-run, and +# `::error::`-loud + red if a dispatch fails (an un-CI'd develop head is +# exactly the silence this block exists to end). Deliberately NO PAT and no +# bot token: explicit dispatch is the sanctioned workaround. +# +# Reference implementation this mirrors: scitex-ai/scitex-agent-container +# PR #809 (merged e947b450), carried to main via #811. on: - workflow_call: {} + workflow_call: + inputs: + dry_run: + description: >- + Report what would be merged/dispatched without doing it. + type: boolean + default: false + required: false + post_merge_gates: + description: >- + Space-separated workflow FILENAMES to dispatch on develop after a + merge (e.g. "pytest-matrix-on-ubuntu-py3-11-3-12-3-13.yml + quality-audit-on-ubuntu-latest.yml"). Leave empty to auto-discover + every active workflow on develop that accepts workflow_dispatch, + minus the deny-list below. Auto-discovery is the default because + leaf repos genuinely differ (scitex-io ships + scitex-io-quality-audit-on-ubuntu-latest.yml, scitex-writer ships + quality-audit-on-ubuntu-latest.yml) — a hardcoded org-wide list + would 404 on half the org and red every sweep. + type: string + default: "" + required: false permissions: contents: write pull-requests: write + # gh workflow run — without this every dispatch 403s and the sweep is + # right back to landing un-CI'd commits on develop. + actions: write jobs: automerge: @@ -27,8 +79,17 @@ jobs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} HEAD_SHA: ${{ github.event.check_suite.head_sha }} + DRY_RUN: ${{ inputs.dry_run }} + POST_MERGE_GATES: ${{ inputs.post_merge_gates }} + # Workflows that must NEVER be auto-dispatched after a merge: + # the sweep itself (recursion), release/publish paths (they cut + # real artifacts), and the CLA bot (needs PR context). + GATE_DENY_RE: '^(auto-merge-to-develop|autobump-release-sweep|cla|pypi-publish|.*release.*|.*publish.*)' run: | set -uo pipefail + + merges=0 + if [ -n "${HEAD_SHA:-}" ]; then prs=$(gh api "repos/$REPO/commits/$HEAD_SHA/pulls" --jq '.[] | select(.state=="open") | .number' 2>/dev/null || true) else @@ -54,8 +115,78 @@ jobs: | (.conclusion // .state // "") | select(. != "SUCCESS" and . != "NEUTRAL" and . != "SKIPPED" and . != "") ] | length' 2>/dev/null || echo 1) if [ "$pending" = "0" ]; then - gh pr merge "$pr" --repo "$REPO" --merge --admin --delete-branch && echo "MERGED #$pr -> develop" || echo "blocked #$pr" + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would merge #$pr -> develop" + merges=$((merges + 1)) + elif gh pr merge "$pr" --repo "$REPO" --merge --admin --delete-branch; then + echo "MERGED #$pr -> develop" + merges=$((merges + 1)) + else + echo "blocked #$pr" + fi else echo "PR #$pr not green ($pending) — skip" fi done + + echo "sweep complete: $merges merge(s) this run" + + # --------------------------------------------------------------- + # POST-MERGE CI DISPATCH — a github.token merge fires NOTHING. + # --------------------------------------------------------------- + # The merge commit we just pushed carries the default workflow + # token, so GitHub suppresses every push-triggered workflow on it + # (see header). Without this block develop's new head has ZERO + # check runs, and a develop-health gate reads that absence as "no + # red signal" — green-by-absence. workflow_dispatch is exempt from + # the suppression, so fire the gates explicitly. ONCE per call + # (merges is this call's total), never per PR. + if [ "$merges" -gt 0 ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would dispatch post-merge gates on develop" + else + gates="$POST_MERGE_GATES" + if [ -z "$gates" ]; then + # Auto-discover: active workflows that exist ON develop and + # accept a bare workflow_dispatch. Reading the file from the + # develop ref (not main) is the point — a gate that only + # exists on main cannot check the head we just landed. + candidates=$(gh api "repos/$REPO/actions/workflows?per_page=100" \ + --jq '.workflows[] | select(.state=="active") | .path | sub("^\\.github/workflows/"; "")' \ + 2>/dev/null || true) + for wf in $candidates; do + echo "$wf" | grep -Eq "$GATE_DENY_RE" && continue + body=$(gh api "repos/$REPO/contents/.github/workflows/$wf?ref=develop" \ + --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true) + [ -z "$body" ] && continue + echo "$body" | grep -q 'workflow_dispatch' || continue + gates="$gates $wf" + done + fi + + if [ -z "$(echo "$gates" | tr -d '[:space:]')" ]; then + # A merge landed and NOTHING can check it. That is the + # green-by-absence state itself, not a clean result. + echo "::error::merged $merges PR(s) onto develop but found NO dispatchable post-merge gate. develop's new head is UN-CHECKED. Add a workflow_dispatch trigger to this repo's CI, or pass post_merge_gates explicitly." + exit 1 + fi + + echo "post-merge gates: $gates" + dispatch_failures=0 + for wf in $gates; do + if gh workflow run "$wf" --repo "$REPO" --ref develop; then + echo "dispatched $wf on develop" + else + # LOUD, never silent: an undispatched gate leaves develop's + # new head UN-CHECKED — the exact hole this block closes. + echo "::error::failed to dispatch $wf on develop — the merged head has NO CI of its own until something fires it" + dispatch_failures=$((dispatch_failures + 1)) + fi + done + if [ "$dispatch_failures" -gt 0 ]; then + echo "::error::$dispatch_failures post-merge gate dispatch(es) failed. The merge itself LANDED; this run is red ON PURPOSE so an un-CI'd develop head cannot pass unnoticed." + exit 1 + fi + echo "post-merge gates dispatched on develop" + fi + fi diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml new file mode 100644 index 0000000..1c10d4c --- /dev/null +++ b/.github/workflows/self-test.yml @@ -0,0 +1,30 @@ +name: self-test + +# The reusable workflows in this repo are the org's shared CI implementation. +# A defect here is a defect in every repo that calls it — so the workflows +# themselves get tests, and those tests run on every change to this repo. +# +# tests/test_auto_merge_dispatch.py is file-only (parses the YAML, asserts on +# the shell body). No network, no gh, cannot flake. + +on: + push: + branches: [main, develop] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: install + run: python -m pip install --upgrade pip pytest pyyaml + - name: run workflow contract tests + run: python -m pytest tests/ -v diff --git a/tests/__pycache__/test_auto_merge_dispatch.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_auto_merge_dispatch.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee0f891d093375f7be8d2110f97b0c8f93818eb6 GIT binary patch literal 17113 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 Lf8K;<*fjhP+4Tu} literal 0 HcmV?d00001 diff --git a/tests/test_auto_merge_dispatch.py b/tests/test_auto_merge_dispatch.py new file mode 100755 index 0000000..034398c --- /dev/null +++ b/tests/test_auto_merge_dispatch.py @@ -0,0 +1,242 @@ +"""The org auto-merge sweep must DISPATCH develop's post-merge gates itself. + +GitHub SUPPRESSES workflow triggers for pushes made with the default +``github.token`` (recursive-run protection), and auto-merge-to-develop.yml +merges with exactly that token — so every commit the sweep lands arrives on +develop with ZERO check runs. Measured across the scitex-ai org on +2026-07-22: 58 of 74 enumerated repos carry at least one bot-merged develop +head with ``check-runs total_count: 0`` while their user-pushed neighbours on +the same branch carry 5-19 each (figrecipe 3ebb01c0 -> 0 vs b33a8ed3 -> 19; +scitex-io 49d709be -> 0 vs f9fe3dd5 -> 8; scitex-app 0e69dfd5 -> 0 vs +827b75c9 -> 16), with runners online and idle. + +A downstream develop-health gate then reads "no checks" as "no red signal" +and keeps merging — green-by-absence. ``workflow_dispatch`` IS exempt from +the suppression, so the merge step must explicitly dispatch the gates after +a successful merge. + +These tests pin that wiring AS TEXT — no network, no gh, cannot flake. +Mutation-checked: reverting the workflow to its pre-fix body, neutering the +``gh workflow run`` line, or flipping ``--ref develop`` to ``--ref main`` +each turn at least one test red. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +_REPO = Path(__file__).resolve().parents[1] +_AUTO_MERGE = _REPO / ".github" / "workflows" / "auto-merge-to-develop.yml" + + +@pytest.fixture(scope="module") +def workflow() -> dict: + return yaml.safe_load(_AUTO_MERGE.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def merge_step(workflow: dict) -> dict: + """The step that performs the merges (found by behaviour, not by name).""" + steps = workflow["jobs"]["automerge"]["steps"] + merging = [s for s in steps if "gh pr merge" in s.get("run", "")] + assert len(merging) == 1, "expected exactly one merging step" + return merging[0] + + +def _dispatch_lines(merge_step: dict) -> list[str]: + return [ + line for line in merge_step["run"].splitlines() if "gh workflow run" in line + ] + + +# --------------------------------------------------------------------------- +# The dispatch exists at all. +# --------------------------------------------------------------------------- + + +def test_merge_step_runs_workflow_dispatch(merge_step: dict) -> None: + # Arrange + run = merge_step["run"] + # Act + lines = [line for line in run.splitlines() if "gh workflow run" in line] + # Assert + assert lines, ( + "the merge step never runs `gh workflow run` — a github.token merge " + "fires no triggers, so develop's new head would sit un-checked" + ) + + +# --------------------------------------------------------------------------- +# THE WRONG-REF HOLE. `--ref main` is a dispatch that exists but checks the +# wrong branch — it would satisfy a naive "is there a dispatch?" test while +# leaving the merged develop head just as un-verified as before. +# --------------------------------------------------------------------------- + + +def test_every_dispatch_targets_develop(merge_step: dict) -> None: + # Arrange: non-emptiness is pinned by test_merge_step_runs_workflow_dispatch. + lines = _dispatch_lines(merge_step) + # Act + off_ref = [line for line in lines if "--ref develop" not in line] + # Assert + assert off_ref == [], ( + "a dispatch without `--ref develop` runs the gate against another " + f"branch, not the head the merge just produced: {off_ref}" + ) + + +def test_no_dispatch_targets_a_non_develop_ref(merge_step: dict) -> None: + # Arrange + lines = _dispatch_lines(merge_step) + # Act: catch the mutation from the other side — `--ref main` must never + # appear even alongside a correct `--ref develop` on some other line. + wrong = [line for line in lines if "--ref main" in line or "--ref master" in line] + # Assert + assert wrong == [], f"dispatch targets a non-develop ref: {wrong}" + + +def _discovery_lines(merge_step: dict) -> list[str]: + return [ + line + for line in merge_step["run"].splitlines() + if "contents/.github/workflows/" in line and "ref=" in line + ] + + +def test_gate_discovery_reads_workflow_files(merge_step: dict) -> None: + # Arrange + step = merge_step + # Act + discovery = _discovery_lines(step) + # Assert + assert discovery, "no gate-discovery read of .github/workflows found" + + +def test_gate_discovery_reads_the_develop_ref(merge_step: dict) -> None: + # Arrange: non-emptiness is pinned by the previous test. + discovery = _discovery_lines(merge_step) + # Act: auto-discovery must inspect workflow files as they exist ON + # develop. Discovering from main would happily "find" a gate that does + # not exist on the branch being merged into. + off_ref = [line for line in discovery if "ref=develop" not in line] + # Assert + assert off_ref == [], f"gate discovery reads a non-develop ref: {off_ref}" + + +# --------------------------------------------------------------------------- +# Reachable only AFTER a merge — once per call, never per PR, never dry. +# --------------------------------------------------------------------------- + + +def test_dispatch_is_guarded_by_the_merge_count(merge_step: dict) -> None: + # Arrange + run = merge_step["run"] + # Act: locate the end of the per-PR merge loop (the first column-0 `done` + # after `gh pr merge`), the merge-count guard, and the dispatch. + loop_end_at = run.index("\ndone\n", run.index("gh pr merge")) + guard_at = run.index('[ "$merges" -gt 0 ]') + dispatch_at = run.index("gh workflow run") + # Assert: the guard opens after the merge loop and before the dispatch, + # so N merges fan out to ONE dispatch round, not N. + assert loop_end_at < guard_at < dispatch_at, ( + "the dispatch must be guarded on `merges > 0` after the merge loop — " + "a per-PR or unconditional dispatch is the wrong shape" + ) + + +def test_dry_run_does_not_dispatch(merge_step: dict) -> None: + # Arrange + run = merge_step["run"] + # Act: the dry-run branch must be decided between the guard and the + # dispatch — a dry run merges nothing, so firing real CI from it would be + # dispatching gates for a merge that never happened. + guarded_block = run[run.index('[ "$merges" -gt 0 ]') : run.index("gh workflow run")] + # Assert + assert '"$DRY_RUN" = "true"' in guarded_block + + +def test_dispatch_failure_is_loud(merge_step: dict) -> None: + # Arrange + run = merge_step["run"] + # Act + after_guard = run[run.index('[ "$merges" -gt 0 ]') :] + # Assert: a failed dispatch leaves develop's new head UN-CHECKED — the + # precise silence this wiring exists to end — so it must go ::error:: + # loud, not vanish into an `|| true`. + assert "::error::" in after_guard, ( + "a failed gate dispatch must be loud (::error::), not silently " + "swallowed" + ) + + +def test_dispatch_failure_reds_the_run(merge_step: dict) -> None: + # Arrange + run = merge_step["run"] + # Act + after_guard = run[run.index('[ "$merges" -gt 0 ]') :] + # Assert + assert "exit 1" in after_guard, "a failed dispatch must red the run" + + +def test_empty_gate_set_is_an_error_not_a_pass(merge_step: dict) -> None: + # Arrange + run = merge_step["run"] + after_guard = run[run.index('[ "$merges" -gt 0 ]') :] + # Act: "we merged and found nothing to dispatch" IS the green-by-absence + # state. A check that could not run must never report what a check that + # passed reports. + empty_branch = [ + line + for line in after_guard.splitlines() + if "::error::" in line and "NO dispatchable" in line + ] + # Assert + assert empty_branch, ( + "an empty gate set must be a loud error — silently dispatching " + "nothing reproduces the exact hole this fix closes" + ) + + +# --------------------------------------------------------------------------- +# The token can actually honour the dispatch. +# --------------------------------------------------------------------------- + + +def test_workflow_token_may_dispatch(workflow: dict) -> None: + # Arrange + permissions = workflow.get("permissions", {}) + # Act: `gh workflow run` needs actions:write; without it every dispatch + # 403s and the sweep is back to landing un-CI'd commits. + granted = permissions.get("actions") + # Assert + assert granted == "write" + + +def _call_inputs(workflow: dict) -> dict: + # YAML 1.1 parses a bare `on:` key as boolean True — read both. + triggers = workflow.get("on", workflow.get(True, {})) + return (triggers.get("workflow_call") or {}).get("inputs") or {} + + +def test_dry_run_input_is_declared(workflow: dict) -> None: + # Arrange + doc = workflow + # Act + inputs = _call_inputs(doc) + # Assert + assert "dry_run" in inputs, "callers cannot request a dry run" + + +def test_post_merge_gates_input_is_declared(workflow: dict) -> None: + # Arrange + doc = workflow + # Act + inputs = _call_inputs(doc) + # Assert + assert "post_merge_gates" in inputs, ( + "callers cannot override auto-discovery — a repo whose gate names " + "the deny-list misjudges would have no escape hatch" + ) From 67e1e34268ac4d17dda52c89c01db6a5431f9ca3 Mon Sep 17 00:00:00 2001 From: Yusuke Watanabe Date: Wed, 22 Jul 2026 17:30:17 +0900 Subject: [PATCH 2/2] chore: gitignore __pycache__ (dropped a stray .pyc from the previous commit) --- .gitignore | 3 +++ ..._merge_dispatch.cpython-312-pytest-9.1.1.pyc | Bin 17113 -> 0 bytes 2 files changed, 3 insertions(+) create mode 100644 .gitignore delete mode 100644 tests/__pycache__/test_auto_merge_dispatch.cpython-312-pytest-9.1.1.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a160f49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ diff --git a/tests/__pycache__/test_auto_merge_dispatch.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_auto_merge_dispatch.cpython-312-pytest-9.1.1.pyc deleted file mode 100644 index ee0f891d093375f7be8d2110f97b0c8f93818eb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17113 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 Lf8K;<*fjhP+4Tu}