Skip to content

chore(ci): adopt the agent-pipeline ci-only posture and migrate branch protection #59

Description

@ivndev001

What to build

Bring this repo onto the agent-pipeline ci-only posture, so /merge-ci-only and /execute-prd-ci-only can actually reach a merge here. One PR plus one protection change, both scoped to this repo.

Filed here rather than in the plugin repo deliberately: the deliverable is a commit in this tree, so the executor produces a real, non-empty PR — it clears the pipeline's empty-diff blocker, which a zero-file PR filed elsewhere would not.

The merge is an operator click, not pipeline work. decideMerge carries an unconditional pipeline-config-park blocker keyed on /(^|\/)\.agent-pipeline\.json$/ (gate.ts:1824-1833; class defined at gate-paths.ts:120-122). Its own text: "UNCONDITIONAL: no posture, standing authorization, reviewGate or merge.classificationParks lifts this park … EXIT: a human reviews the config change deliberately and merges it themselves — it is not pipeline work." The rationale (#485) is that this file is where a supply block names which issues the pipeline may treat as its own work scope — a config that grants scope, reachable by what it grants scope to — so it must never merge unread.

An agent can therefore draft the config, open the PR and drive it to green CI. A human merges it. That is why this issue is HITL, and no label or config change makes it AFK.

1. Commit .agent-pipeline.json (the PR)

Minimum shape:

{
  "repo": "developerz-ai/ui-debugger-mcp",
  "merge": { "reviewGate": "ci-only" }
}

For this repo, omit ci.requiredChecks entirely — the scope-guard acceptance criterion below pins the committed config to exactly two scalar paths (repo, merge.reviewGate), and a ci.requiredChecks array adds a third. The general rule, for reference elsewhere: add it only if every context named runs on every PR — verify against a live PR head first. When unsure, omit it: an absent requiredChecks falls through to evaluateCiAllGreen, where every observed check must be completed and non-failing and a zero-check head fails closed. A named context that does not run scores absent (fail-closed) and parks every PR forever.

2. Migrate branch protection to a main-protection ruleset

deletion + non_fast_forward + pull_request, with the pull_request parameters at:

  • required_approving_review_count: 0
  • require_code_owner_review: false
  • require_last_push_approval: false
  • dismiss_stale_reviews_on_push: false

Then delete the classic rule. Do not add required_status_checks unless every listed context runs on every PR.

Ordering matters and is not optional: the ruleset migration must land before the step-1 PR can merge, because the live classic rule still requires an approving review. This step is operator-run — it mutates repo settings out-of-band, so no PR contains it and no revert undoes it. Record the pre-change values (below) before touching anything.

All four parameters matter, not just the count — each is an independent merge blocker in GitHub's own semantics, so a repo migrated to count 0 with require_last_push_approval or stale-review dismissal left on passes a count-only check and stays blocked. That is the reason to set all four; it is derived from the API contract, not from a sample.

Corrected fleet survey (verified live 2026-08-07 via gh api repos/<r>/rules/branches/main and .../branches/main/protection). An earlier revision of this issue claimed eight repos "all verify at exactly these values". That is false for seven of the eight:

repo pull_request rule count / codeowner / lastpush / dismiss classic protection
ivndev001/agent-pipeline yes 0 / false / false / false 404
ai-designer yes 0 / false / false / true 404
db-mcp-gateway yes (+required_status_checks) 0 / false / false / true live, 4 contexts
developerz.ai none live, 4 contexts
minime-app yes 0 / false / false / true 404
multi-ai-router no ruleset at all live, 5 contexts
venom-astrology yes 0 / false / false / true live, 0 contexts
venomtechmedellin.com none 404

Only ivndev001/agent-pipeline matches all four target values. Four repos sit at count 0 with dismiss_stale_reviews_on_push: true — so that parameter is not empirically load-bearing for reaching a merge, and the target shape here is the strict one by choice, not by precedent. Three repos are not migrated at all. Do not cite the fleet as evidence that this shape is already proven.

What this trades, stated plainly

required_approving_review_count: 0 does not delete review — it transfers it from a GitHub click to the plugin's two-lens deep-review gate plus fail-closed CI. On a repo at count 1 an approving bot review already satisfies the requirement, which is the specific hazard developerz-ai/wurk's committed .maintainer.yml records as its reason for pinning auto-merge off on 2026-08-01.

This repo's current state

Verified live 2026-08-07. Public repo. No rulesets (rules/branches/main returns []). Classic protection: required_approving_review_count: 1, require_code_owner_reviews: false, require_last_push_approval: false, dismiss_stale_reviews: false, enforce_admins: false, one context bun (lint + typecheck + test). No CODEOWNERS (404).

This is the repo behind the whole spec — Din's #57 sits here, and its classic count of 1 is what the gate cannot currently see (#526).

Acceptance criteria

  • .agent-pipeline.json on the default branch, parsing cleanly, declaring repo and merge.reviewGate
  • Scope guard (fails in the UNSAFE direction). jq -r 'paths(scalars)|join(".")' .agent-pipeline.json | sort emits exactly merge.reviewGate and repo — nothing else. Goes red if the committed posture lands more permissive than specified (a stray merge.standingAuthorization, merge.classificationParks, policy.module, or a ci.requiredChecks naming a context that does not run on every PR). Every other criterion here only catches an incomplete migration; this one catches an over-broad one.
  • the committed repo value equals this repo's full slug. governsRepo (config.ts:880) is an exact case-insensitive full-slug match, so a typo'd slug ships an inert config that passes every other criterion here while merge.reviewGate is never read (merge.ts:1893) and the cr-approved gate silently stands. Key presence is not enough.
  • main-protection ruleset with all four pull_request parameters at their target values
  • classic branches/main/protection returns the literal message Branch not protectednot merely "a 404". That endpoint requires repo admin, and a caller without it also gets a 404: {"message":"Not Found", … ,"status":"404"}. The two share the status code but differ in the message body (verified live 2026-08-07 against anthropics/claude-code for the non-admin case and ivndev001/agent-pipeline for the genuinely-unprotected case), which is exactly what makes matching the literal string sound and matching the bare status code unsound. Assert the message; route any other 404 to LIVE_TEST_INCONCLUSIVE, never PASS.
  • gh pr view <n> --json reviewDecision,mergeStateStatus returns an empty/null reviewDecision. Null is the ci-only premise, not a failure (config.ts:106: the posture exists precisely because "reviewDecision is structurally null there"). A non-null value means the migration did not take.
  • Ruleset completeness (fails in the UNSAFE direction). gh api repos/<r>/rules/branches/main --jq '[.[].type]|unique|sort|join(",")' contains all three of deletion, non_fast_forward, pull_request. Classic protection carries allow_force_pushes: false and allow_deletions: false today; a pull_request-only ruleset that replaces it passes every review-related criterion here while leaving main force-pushable and deletable. That is the single most damaging way this migration can go wrong, and nothing else on this list detects it.
  • No bypass actors (fails in the UNSAFE direction). For every ruleset_id returned above, gh api repos/<r>/rulesets/<id> --jq '[.bypass_actors[]?]|length' returns 0. A bypass actor silently reopens every gate the ruleset appears to close. Reference shape, live on ivndev001/agent-pipeline: rule_types: [deletion, non_fast_forward, pull_request], bypass_actors: [], conditions.ref_name.include: ["~DEFAULT_BRANCH"], enforcement: "active".
  • gh pr view <n> --repo <repo> --json mergeStateStatus returns CLEAN on a real open PR — the only check that catches a surviving require_last_push_approval
  • live test below passes post-merge

AFK / HITL

HITL.

Not a preference — a mechanical fact. Step 1's PR parks at pipeline-config-park, which nothing lifts (see What to build). Step 2 is a GitHub settings mutation (branch ruleset + deletion of the classic rule): it is not a tree artifact, CI cannot gate it, and a revert of the merge commit does not reach it. Flipping this issue to afk does not make an unattended run able to finish it; it makes the run open a PR and then park.

Live test

set -uo pipefail
R=developerz-ai/ui-debugger-mcp

command -v jq >/dev/null || { echo "LIVE_TEST_INCONCLUSIVE: jq is missing on this runner"; exit 1; }

cfg="$(gh api "repos/$R/contents/.agent-pipeline.json" -H "Accept: application/vnd.github.raw" 2>&1)"; rc=$?
if [ "$rc" -ne 0 ]; then
  case "$cfg" in
    *"Not Found"*|*404*)
      # A contents 404 is ambiguous on a private repo: absent file, or a token that cannot
      # read CONTENTS at all (GitHub answers 404, not 403). Discriminate with a SECOND
      # contents read of a path that pre-exists this change - never the bare repos/$R
      # metadata route, which a contents-blind token passes forever.
      ctl="$(gh api "repos/$R/contents/README.md" -H "Accept: application/vnd.github.raw" 2>&1)"; ctlrc=$?
      if [ "$ctlrc" -ne 0 ]; then
        printf '%s\n' "$ctl" | tail -c 200
        echo "LIVE_TEST_INCONCLUSIVE: 404 for .agent-pipeline.json and the README.md control read failed too - a contents-blind token and an absent file are the same 404"
        exit 1
      fi
      # Readable repo: a just-merged path can still 404 while the contents view serves the
      # pre-merge default branch. Budget is a floor, not a pick - 1333-8537 ms of positive-path
      # settle lag measured on agent-pipeline. Expiry is INCONCLUSIVE, never FAIL: it is
      # indistinguishable from a budget sized too small, and FAIL arms revert.
      deadline=$((SECONDS + 9))
      while [ "$SECONDS" -lt "$deadline" ]; do
        sleep 2
        cfg="$(gh api "repos/$R/contents/.agent-pipeline.json" -H "Accept: application/vnd.github.raw" 2>&1)"; rc=$?
        [ "$rc" -eq 0 ] && break
      done
      if [ "$rc" -ne 0 ]; then
        echo "LIVE_TEST_INCONCLUSIVE: .agent-pipeline.json still 404s after a 9s settle budget with a readable repo - a human must tell a missing file from an unsettled view"
        exit 1
      fi ;;
    *) printf '%s\n' "$cfg" | tail -c 200
       echo "LIVE_TEST_INCONCLUSIVE: could not read $R contents from this runner"
       exit 1 ;;
  esac
fi

gate="$(printf '%s' "$cfg" | jq -r '.merge.reviewGate // "absent"')"
if [ "$gate" != "ci-only" ]; then
  echo "FAIL: .agent-pipeline.json declares merge.reviewGate=$gate, expected ci-only"
  exit 1
fi

slug="$(printf '%s' "$cfg" | jq -r 'if has("repo") then (.repo|tostring) else "absent" end')"
if [ "$slug" != "$R" ]; then
  echo "FAIL: .agent-pipeline.json declares repo=$slug, expected $R - governsRepo (config.ts:880) is an exact case-insensitive full-slug match, so a mismatch ships an INERT config: merge.reviewGate is never read (merge.ts:1893) and the hard cr-approved gate silently stands"
  exit 1
fi

rs="$(gh api "repos/$R/rules/branches/main" 2>&1)"; rsrc=$?
if [ "$rsrc" -ne 0 ]; then
  printf '%s\n' "$rs" | tail -c 300
  echo "LIVE_TEST_INCONCLUSIVE: could not read $R branch rules from this runner"
  exit 1
fi
params="$(printf '%s' "$rs" | jq -c '[.[] | select(.type=="pull_request") | .parameters][0] // empty')"
if [ -z "$params" ]; then
  echo "FAIL: $R main carries no pull_request ruleset after the migration"
  exit 1
fi
# jq's `//` substitutes its RHS for `false` as well as `null`, and `false` is the TARGET
# value for three of these four - so the `// "absent"` form can never yield the string
# "false" the guard below tests for, and a flawless migration scores a hard FAIL. Use
# has()/tostring: it yields "false" on the target, "absent" on a missing key and "true" on
# a wrong value - both failure directions closed. A bare `| tostring` is NOT a substitute:
# on an absent key it yields the string "null" on some jq builds, which is luck, not a guard.
count="$(printf '%s' "$params" | jq -r 'if has("required_approving_review_count") then (.required_approving_review_count|tostring) else "absent" end')"
owner="$(printf '%s' "$params" | jq -r 'if has("require_code_owner_review") then (.require_code_owner_review|tostring) else "absent" end')"
lastpush="$(printf '%s' "$params" | jq -r 'if has("require_last_push_approval") then (.require_last_push_approval|tostring) else "absent" end')"
dismiss="$(printf '%s' "$params" | jq -r 'if has("dismiss_stale_reviews_on_push") then (.dismiss_stale_reviews_on_push|tostring) else "absent" end')"
if [ "$count" != "0" ] || [ "$owner" != "false" ] || [ "$lastpush" != "false" ] || [ "$dismiss" != "false" ]; then
  echo "FAIL: $R ruleset is not merge-ready (count=$count code_owner=$owner last_push=$lastpush dismiss_stale=$dismiss)"
  exit 1
fi

types="$(printf '%s' "$rs" | jq -r '[.[].type]|unique|sort|join(",")')"
for t in deletion non_fast_forward pull_request; do
  case ",$types," in
    *",$t,"*) ;;
    *) echo "FAIL: $R main ruleset is missing the $t rule (types=$types) - classic protection's force-push/deletion guarantees would be lost"
       exit 1 ;;
  esac
done

for id in $(printf '%s' "$rs" | jq -r '[.[].ruleset_id]|unique|.[]'); do
  ba="$(gh api "repos/$R/rulesets/$id" --jq '[.bypass_actors[]?]|length' 2>&1)"; barc=$?
  if [ "$barc" -ne 0 ]; then
    printf '%s\n' "$ba" | tail -c 200
    echo "LIVE_TEST_INCONCLUSIVE: could not read ruleset $id bypass actors from this runner (needs repo admin)"
    exit 1
  fi
  if [ "$ba" != "0" ]; then
    echo "FAIL: $R ruleset $id carries $ba bypass actor(s) - every gate above is reopened for them"
    exit 1
  fi
done

prot="$(gh api "repos/$R/branches/main/protection" 2>&1)"; prc=$?
if [ "$prc" -eq 0 ]; then
  echo "FAIL: $R still carries legacy classic branch protection alongside the ruleset"
  exit 1
fi
case "$prot" in
  *"Branch not protected"*) echo "PASS: $R is on the ci-only posture with a ruleset at count 0 and no classic protection" ;;
  *) printf '%s\n' "$prot" | tail -c 300
     echo "LIVE_TEST_INCONCLUSIVE: could not determine $R classic protection state"
     exit 1 ;;
esac

Rollback

Revert the .agent-pipeline.json commit and restore the classic protection rule from the values recorded above. No automation reverts this: the plugin's revert.armOnLiveTestFail is not set on any repo here, so a live-test FAIL escalates rather than reverting.

Blocked by

ivndev001/agent-pipeline#525 — the owner ruling that authorizes the fleet-wide ci-only flip.

This is a fail-open change (it relaxes required_approving_review_count 1 → 0, drops the required status-check context that classic protection enforces today, and makes a null reviewDecision count as an accept). The only committed ruling in range is a0c4b28 (2026-07-27), which by its own text says "The grant removes the human YES and nothing else" — so it does not authorize this. ivndev001/agent-pipeline#525 is the artifact that would, and it is still open. Per the parent epic's own discipline: the authorizing artifact precedes the code it authorizes. (That epic is referenced under ## Context below, deliberately not here — this section's refs are parsed as blockers, and blocking on a parent epic that stays open until its children close would be circular.)

Context

Part of ivndev001/agent-pipeline#524 — peer-PR merge lane and fleet-wide ci-only enablement.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions