fix(maintainer): preserve validated PR landing progress

Avoid main-only refreshes and repeated blocked routes. Add explicit prior-CI
admin admission for reviewed conflict repairs, pinned to current preparation,
verified earlier CI and current review/security requirements. Preserve honest
operator validation evidence in the native retained merge outcome and dispatch
through the protected SHA-pinned REST squash endpoint.

Pass complete CI changed-path manifests through files so large PRs retain
Node planning inputs beyond the 64 KiB Actions output limit. Keep frozen-target
compatibility and reject missing or malformed current manifests.

Validation: reproduced the historical oversized-manifest failure; 570 CI scope
and workflow cases pass (241.60s wall). Fifteen native admission/dispatch cases
pass (45.90s wall), four existing admission/recovery cases pass (52.48s), and
wrapper inventory passes (21.89s). New manifest cases add subsecond test time;
native process cases cover actual pinned dispatch, authority and receipt gates.

Type checks, typed lint, workflow validation, formatting and source ratchets pass.
Review corrections cover absent or wrong-publisher security checks and ref-suffixed
workflow paths; all three fail against the previous predicates.
This commit is contained in:
Peter Steinberger
2026-09-27 13:27:14 -07:00
parent 000d03942c
commit b2e0e5627c
15 changed files with 799 additions and 64 deletions
@@ -38,6 +38,19 @@ Refresh only for a conflict, failing guard, explicit request, or material stale
base risk. An explicitly requested landing of one's own draft includes marking
it ready when needed.
For a conflict repair, record the last passing run and tested head, the resolved
delta, and the affected contracts in the existing preparation evidence. Reuse
proof for unchanged inputs; run the affected checks instead of restarting every
completed suite. A passing older run is not current-head CI and does not itself
waive enforced gates. Once admission succeeds, merge before optional proof polish
or unrelated cleanup.
Keep source PRs within their generation owners: UI/native translation memory and
locale metadata normally belong to the post-merge locale workflows. Check a
hosted review bundle's size before submission; remove accidentally included
generated outputs through their owning workflow, not by truncating review input
or silently excluding authored changes.
## Evidence media
Read the [media upload reference](media.md) for feature detection, endpoint
@@ -121,6 +134,41 @@ The merge workflow still owns later main-drift policy. For explicitly
owner-approved reviewed fork code without hosted Testbox, use the documented
`OPENCLAW_PR_GATES_REMOTE=testbox` path.
### Explicit prior-CI admin landing
When the operator explicitly authorizes landing after a prior successful CI run
and reviewed conflict repairs, prepare the current head with `github_pending`
and use the native exception below. Ordinary land authority alone does not select
this exception. Keep the completed review and current prepared-head bindings.
```bash
node scripts/pr-lib/merge-prior-ci.mjs delta <prior-green-head> <prepared-head>
scripts/pr merge-run <pr> --admin-evidence <evidence.json> --confirmed-operator-admin
```
The delta command reports both heads, `deltaSha256`, and `changedPaths`. Inspect
that delta, use the changed-check planner to select affected checks, and record
their actual results. The evidence JSON requires `version: 1`, `repository`,
numeric `pr`, `head`, `priorHead`, numeric `runId` and `runAttempt`, `deltaSha256`,
`changeKind: "conflict-resolution"`, an operator `reason`, affected `contracts`
as strings, and `checks` entries with `command`, `result: "passed"`, and an
`evidence` description. These scoped results are explicit operator attestations,
not synthesized current-head CI success.
The tool verifies the earlier successful attempt's PR/head provenance, including
its CI gate. PR runs need the matching PR association; manually dispatched runs
need the current same-repository PR branch and an ancestor tested head. It
rechecks the current writer's repository and active organization-admin authority
and permits only pending/skipped normal CI. Failed required checks, security
requirements, enforced reviews and unresolved required review
threads still block. This mode supports immediate squash on github.com with
known ruleset policy, not classic protection, queues, auto-merge, or recovery.
It dispatches the protected REST merge with the exact head pinned and retains
the prior run, inspected delta, scoped evidence, and operator in the existing
merge outcome. Accepted or uncertain outcomes still require reconciliation.
### Completed-evidence follow-through
For a requested diagnosis or the completed-evidence path, watch one exact head
with `node scripts/watch-pr-ci.mjs <pr> <head-sha>`; use narrow JSON check/run reads
and fetch failed logs once. Address substantive human/bot findings and resolve
@@ -177,6 +225,12 @@ recover only with the exact token and command the wrapper printed. Never remove
locks by hand or start competing retries. After throttling, inspect quota before
retrying native prepare/merge.
After two identical pre-dispatch failures without new evidence, stop invoking
the same blocked route. Inspect the failure and select an already-authorized
supported route with the exact reviewed head pinned, or report the concrete
missing capability. A transport change never waives admission or authorizes
replaying an accepted or uncertain request.
A failed or timed-out merge response can still mean GitHub merged it. Reconcile
remote state and ancestry before retrying. Verify the final merge commit is on
current main; do not count a draft, pending check, or local summary as landing.
+6 -1
View File
@@ -1420,6 +1420,7 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_RUN_UI_TESTS: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }}
OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}
OPENCLAW_CI_CHANGED_PATHS_FILE: ${{ steps.changed_scope.outputs.changed_paths_file }}
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_scope.outputs.changed_paths_json || 'null' }}
OPENCLAW_CI_CHANGED_BASE: ${{ steps.diff_base.outputs.sha }}
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
@@ -1586,7 +1587,11 @@ jobs:
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
const changedPaths = (() => {
try {
const value = JSON.parse(process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "null");
const manifestPath = process.env.OPENCLAW_CI_CHANGED_PATHS_FILE;
// Frozen target producers can predate the complete file transport.
const value = JSON.parse(manifestPath
? readFileSync(manifestPath, "utf8")
: process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "null");
return Array.isArray(value) && value.every((path) => typeof path === "string")
? value
: null;
+2 -1
View File
@@ -33,8 +33,9 @@ Update instructions at their owner instead of adding competing rules here.
- Separate product bugs from tool/fixture failures.
- Check relevant prerequisites early. Parallelize independent work.
- New check needs named unknown, risk, or required gate. Reuse valid proof.
- Repeated failures or 10 min without new evidence: change approach. No blind retries.
- After two identical tooling failures without new evidence, or 10 min without progress, change approach. Use an already-authorized supported alternative with the reviewed head pinned; reconcile uncertain writes before retrying. No blind retries or guard bypasses.
- Behavior proven + required gates green: finish/land. No optional proof polish or speculative scope growth.
- Do not rebase or merge `main` solely because it advanced. Check mergeability first; integrate for actual conflicts or a named failing gate/material base risk. Preserve valid review and validation evidence, rerun checks affected by the resolution, and land promptly once the selected workflow's gates are satisfied. Prior-head CI remains prior-head evidence; an explicit admin exception belongs to the native landing workflow.
- Time pressure never waives gates. Report concrete blockers.
- Visual change: inspected before/after screenshots. Behavior-only fix: direct boundary proof. No checkbox demos.
+5
View File
@@ -32,6 +32,11 @@ Hourly iOS retains `ios-build (tests)` with Rust, voice, native Access, and focu
Eligible core-source and core-test PRs use targeted type checks when every selected path exists in the checkout. GitHub and hybrid profiles distribute the selected consumers across their existing core stripes; the Blacksmith profile checks them in the central row. Ambiguous ownership and deleted core tests keep the full type-check coverage.
Preflight passes the complete changed-path manifest between steps as a local JSON
file, so large PRs do not lose test-planning inputs to Actions output or environment
size limits. Frozen targets that predate this transport retain their bounded JSON
output contract. Missing or invalid inputs still reject current PR Node planning.
The [Testbox check workflow](/ci/local-proof#testbox-validation) defaults to a four-hour outer job budget for delegated full-suite proof. Individual test deadlines remain unchanged.
Full GitHub and hybrid type checks run the five core stripes independently, retaining two compiler children per job. Current hybrid runs also split extension lint across six hosted jobs. Trusted hybrid first attempts place the heavy first packed core-lint row on the Blacksmith 16-class, the second on the 8-class, and the final gate on the 4-class to avoid serial hosted assignment delays. Frozen targets keep their earlier layout; see [static checks](/ci/runners#runner-backend-modes).
+16 -2
View File
@@ -27,7 +27,7 @@ This directory owns local tooling, script wrappers, and generated-artifact helpe
## PR Prepare Gates
- The default agent handoff uses `OPENCLAW_PR_GATES_REMOTE=github` and `merge-run --auto-merge`. Preparation records `github_pending` bound to the published head without successful-proof stamps. Merge requires completed review, the exact prepared head, and the enforced `openclaw/ci-gate`; it rejects known failed required checks, accepts pending checks, and skips separate hosted workflow verification and its synchronous CI watcher. GitHub enforces required CI/security checks and reviews; the agent retains responsibility for follow-through. This mode replaces separate scheduled Testbox evidence with the PR's enforced gate; existing completed-evidence modes remain available. Accepted requests return pending, not completion. Follow the maintainer skill's polling cadence, investigate failures and conflicts, and reconcile through native recovery until merge and cleanup are verified. Preserve accepted or uncertain outcome records; never blindly re-arm a request. No admin or REST fallback applies to pending-gate admission.
- The default agent handoff uses `OPENCLAW_PR_GATES_REMOTE=github` and `merge-run --auto-merge`. Preparation records `github_pending` bound to the published head without successful-proof stamps. Merge requires completed review, the exact prepared head, and the enforced `openclaw/ci-gate`; it rejects known failed required checks, accepts pending checks, and skips separate hosted workflow verification and its synchronous CI watcher. GitHub enforces required CI/security checks and reviews; the agent retains responsibility for follow-through. This mode replaces separate scheduled Testbox evidence with the PR's enforced gate; existing completed-evidence modes remain available. Accepted requests return pending, not completion. Follow the maintainer skill's polling cadence, investigate failures and conflicts, and reconcile through native recovery until merge and cleanup are verified. Preserve accepted or uncertain outcome records; never blindly re-arm a request. No implicit admin or REST fallback applies to pending-gate admission; explicitly authorized prior-CI admin admission is a separate mode below.
- Gate-mode validation for `prepare-run`, `prepare-gates`, and `prepare-push` happens before PR reads, lock acquisition, or preparation evidence retirement. Select one mode: the GitHub-pending invocation clears `OPENCLAW_TESTBOX`; completed hosted proof clears `OPENCLAW_PR_GATES_REMOTE`. Unknown or contradictory selectors must fail without replacing saved evidence. This validation does not block merge-outcome reconciliation, whose retained intent owns recovery.
- Normal `prepare-init` requires incoming-head READY. To resolve a validated incoming NEEDS WORK review with BLOCKER/IMPORTANT findings, explicitly use `scripts/pr prepare-correction-init <PR>`. This initializes correction preparation only, preserving the incoming review and contributor ancestry. Commit the fixes, then use `prepare-correction-review-init` to create a separate exact-candidate JSON review template. JSON alone is authoritative; validation renders its summary. Independently review the full corrected candidate and explain resolution of every required incoming finding. Gates, push, sync and merge require that candidate's READY review; changing the candidate or incoming review invalidates it. Discussion/rejection verdicts cannot use this route. This does not change canonical-wrapper trust or permit use of an unlanded wrapper on another PR. Correction publication does not accept `github_pending`; use a completed exact-candidate gate mode or the separately authorized protected Crabbox pending route.
- PR source acquisition fetches the full head SHA authenticated by live PR metadata from the canonical origin, verifies the fetched commit, and checks that head SHA, branch, and repository identity stayed unchanged across the fetch. GitHub's asynchronous `refs/pull/<PR>/head` projection is not source authority. All review, prepare, publication, and merge fetches use this owner without changing the private main checkpoint or shared tracking refs.
@@ -138,12 +138,26 @@ intact. Octopool 0.6.10 and `641ce3c` do not support that auto shape.
Prepare's reviewer assignment uses the exact issue-assignee POST with raw
`assignees[]` fields. Fork commit publication declares its GraphQL JSON with
`--input`, so the guard can inspect it; Octopool's aggregate input bound still
applies. Native admin, non-squash, queue, and auto-cancellation variants are not
applies. Native CLI admin, non-squash, queue, and auto-cancellation variants are not
covered by the accepted shapes above. Do not replace them with an immediate REST
merge, which changes admission semantics. A blocked dispatch still follows the
retained-outcome recovery rules below; the generic guard error is not authority
to clear or retry an intent.
The explicit `merge-run --admin-evidence <file> --confirmed-operator-admin` mode
is a separate immediate-squash admission, not a fallback from auto/queue or a
failed request. It verifies a prior successful CI attempt and its PR/head
provenance, binds the reviewed prior-to-prepared delta and scoped-check
attestations, and revalidates active organization/repository-admin authority and
effective review rules. Only pending/skipped `openclaw/ci-gate` may be waived;
failed checks, other required checks, and required reviews remain blocking.
Exact-head `github_pending` preparation remains pending. GraphQL owns
observations and reconciliation; the protected REST PUT above owns the SHA-pinned
dispatch. The existing retained outcome owns `priorCiAdmin` evidence and retains
its prior head object. Admin snapshot stability and the landing-parent audit
still apply. See the [landing workflow](../.agents/skills/openclaw-pr-maintainer/references/landing.md#explicit-prior-ci-admin-landing)
for the evidence fields and supported policy limits.
## Generated Outputs
- If a script writes generated artifacts, keep the source-of-truth generator, the package script, and the matching verification/check command aligned.
+6 -1
View File
@@ -1,6 +1,7 @@
// Determines CI scope from changed paths.
import { execFileSync } from "node:child_process";
import { appendFileSync, readFileSync, readdirSync } from "node:fs";
import { appendFileSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { requireOptionArgument } from "./lib/arg-utils.runtime.mjs";
import { getChangedPathFacts } from "./lib/changed-path-facts.mjs";
import {
@@ -796,6 +797,10 @@ export function writeGitHubOutput(
"utf8",
);
const changedPathsJson = JSON.stringify(changedPaths);
// Same-job consumers read the complete manifest without Actions output or env limits.
const changedPathsFile = resolve(`${outputPath}.changed-paths.json`);
writeFileSync(changedPathsFile, changedPathsJson, "utf8");
appendFileSync(outputPath, `changed_paths_file=${changedPathsFile}\n`, "utf8");
appendFileSync(
outputPath,
`changed_paths_json=${Buffer.byteLength(changedPathsJson, "utf8") <= CHANGED_PATHS_OUTPUT_MAX_BYTES ? changedPathsJson : "null"}\n`,
+20 -3
View File
@@ -530,13 +530,16 @@ Usage:
scripts/pr prepare-run <PR>
scripts/pr ci-dispatch <PR> [--backend crabbox]
scripts/pr merge-verify <PR>
scripts/pr merge-run <PR> [--auto-merge] [--body-file <path>]
scripts/pr merge-run <PR> [--auto-merge] [--body-file <path>] [--admin-evidence <path> --confirmed-operator-admin]
OPENCLAW_PR_MERGE_METHOD=merge|rebase preserves the PR commit series.
--auto-merge selects pinned immediate squash for CLEAN, auto for BEHIND/BLOCKED (MERGEABLE only).
OPENCLAW_PR_GATES_REMOTE=github prepare-run defers required CI to this auto-merge handoff.
OPENCLAW_PR_AUTO_MERGE=1 is equivalent.
--body-file snapshots a regular UTF-8 file relative to the caller, replacing squash prose.
Empty files are valid. Explicit/source co-authors and preview credit backed by tree-changing PR commits or PR authorship are retained; known machine credit is excluded. Queue merges reject it.
--admin-evidence explicitly admits prior successful CI plus reviewed conflict validation, with active organization-admin authority.
Only pending/skipped normal CI may be waived; security checks and enforced reviews remain required.
This requires --confirmed-operator-admin, immediate squash, and no auto/recovery request.
Repeated merge-run reconciles retained outcomes; it never retries an uncertain dispatch.
scripts/pr merge-recover <PR> <OUTCOME_OID> --confirmed-operator-recovery [--replacement-head <SHA>] [--body-file <path>] [--legacy-refusal <evidence-directory>] [--pre-dispatch-refusal <evidence-directory>]
Explicitly authorize one new attempt after inspecting the retained outcome and remote history.
@@ -645,7 +648,7 @@ main() {
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ "$3" = --confirmed-operator-completion ] || { usage; exit 2; }
;;
merge-run | merge-recover)
local merge_pr="${1-}" auto_merge=false recovery_oid="" replacement_head="" body_path="" legacy_directory="" cancel_auto=false refusal_directory=""
local merge_pr="${1-}" auto_merge=false recovery_oid="" replacement_head="" body_path="" legacy_directory="" cancel_auto=false refusal_directory="" admin_evidence="" confirmed_admin=false
[ -n "$merge_pr" ] || { usage; exit 2; }
shift
if [ "$cmd" = merge-recover ]; then
@@ -661,6 +664,16 @@ main() {
cancel_auto=true
shift
;;
--admin-evidence)
[ "$cmd" = merge-run ] && [ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$admin_evidence" ] || { usage; exit 2; }
admin_evidence="$2"
shift 2
;;
--confirmed-operator-admin)
[ "$cmd" = merge-run ] && [ "$confirmed_admin" = false ] || { usage; exit 2; }
confirmed_admin=true
shift
;;
--auto-merge)
[ "$cmd" = merge-run ] && [ "$auto_merge" = false ] || { usage; exit 2; }
auto_merge=true
@@ -693,6 +706,10 @@ main() {
if [ "$cmd" = merge-run ] && [ "${OPENCLAW_PR_AUTO_MERGE:-}" = 1 ]; then
auto_merge=true
fi
if [ -n "$admin_evidence" ] || [ "$confirmed_admin" = true ]; then
[ -n "$admin_evidence" ] && [ "$confirmed_admin" = true ] && [ "$auto_merge" = false ] &&
[ "${OPENCLAW_PR_MERGE_METHOD:-squash}" = squash ] || { usage; exit 2; }
fi
[ -z "$legacy_directory" ] || [ -n "$replacement_head" ] || { usage; exit 2; }
[ -z "$refusal_directory" ] || [ -z "$legacy_directory" ] || { usage; exit 2; }
[ "$cancel_auto" = false ] || [ -z "$replacement_head$body_path$legacy_directory$refusal_directory" ] || { usage; exit 2; }
@@ -852,7 +869,7 @@ main() {
merge_verify "$pr" '{"replacementHead":"","autoMergeRequested":false,"qualifiedRefusal":false,"observation":null}'
;;
merge-run | merge-recover)
merge_run "$merge_pr" "$auto_merge" "$recovery_oid" "$replacement_head" "$body_path" "$legacy_directory" "$cancel_auto" "$refusal_directory"
merge_run "$merge_pr" "$auto_merge" "$recovery_oid" "$replacement_head" "$body_path" "$legacy_directory" "$cancel_auto" "$refusal_directory" "$admin_evidence" "$confirmed_admin"
;;
merge-complete)
merge_complete "$1" "$2"
+29 -5
View File
@@ -181,6 +181,11 @@ merge_outcome_load_local() {
(.method == "squash" or .method == "merge" or .method == "rebase") and
(.route == "immediate" or .route == "admin" or .route == "auto" or .route == "queue") and
(if has("transport") then .transport == "rest" and .method == "squash" and .route == "immediate" else true end) and
(if has("priorCiAdmin") then . as $record | .route == "admin" and .method == "squash" and
(.priorCiAdmin | .version == 1 and .head == $record.head and .pr == $record.pr and
.repository == $record.repo.nameWithOwner and (.priorHead | oid) and
(.evidenceSha256 | test("^[0-9a-f]{64}$")) and (.deltaSha256 | test("^[0-9a-f]{64}$")) and
(.actor | type == "string" and length > 0)) else true end) and
(.accepted | type == "boolean") and
(if .phase == "intent" then .landed == null else
(.phase == "merged" or .phase == "commenting" or .phase == "commented" or .phase == "complete") and (.landed | oid) end))
@@ -189,7 +194,7 @@ merge_outcome_load_local() {
merge_outcome_stop "invalid retained repository identity"; return 1;
}
parents=$(GIT_NO_LAZY_FETCH=1 pr_git cat-file commit "$MERGE_OUTCOME_OID" | awk 'NF == 0 {exit} $1 == "parent" {printf "%s ", $2}') || return 1
for retained in $(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -r '[.head,.main,.landed,.localHead,.legacyRefusal.head,.legacyRefusal.preparedBase] | .[] | select(. != null)'); do
for retained in $(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -r '[.head,.main,.landed,.localHead,.legacyRefusal.head,.legacyRefusal.preparedBase,.priorCiAdmin.priorHead] | .[] | select(. != null)'); do
case " $parents " in *" $retained "*) ;; *) merge_outcome_stop "record does not retain required commit $retained"; return 1 ;; esac
GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "$retained^{commit}" || { merge_outcome_stop "required historical commit $retained is unavailable"; return 1; }
done
@@ -258,7 +263,7 @@ merge_outcome_write() {
shift
mark_pr_operation_side_effects_started || return 1
local parents=()
for parent in $(printf '%s\n' "$record" | jq -r '[.head,.main,.landed,.localHead,.legacyRefusal.head,.legacyRefusal.preparedBase] | unique | .[] | select(. != null)'); do
for parent in $(printf '%s\n' "$record" | jq -r '[.head,.main,.landed,.localHead,.legacyRefusal.head,.legacyRefusal.preparedBase,.priorCiAdmin.priorHead] | unique | .[] | select(. != null)'); do
parents+=(-p "$parent")
done
[ -z "$MERGE_OUTCOME_OID" ] || parents+=(-p "$MERGE_OUTCOME_OID")
@@ -317,7 +322,7 @@ merge_outcome_write() {
merge_rest() {
local mode="$1" pr="$2" repo="${MERGE_REPO:-}"
shift 2
if [ "$mode" = observe ] && [ "${MERGE_ADMISSION_ACTIVE:-false}" = true ] && [ "${MERGE_USE_CRABBOX_ADMIN_BYPASS:-false}" = false ]; then
if [ "$mode" = observe ] && [ "${MERGE_ADMISSION_ACTIVE:-false}" = true ] && [ "${MERGE_USE_CRABBOX_ADMIN_BYPASS:-false}" = false ] && [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = false ]; then
mode=observe-admission
fi
[ -n "$repo" ] || repo=$(pr_gh_plain repo view --json id,nameWithOwner,url) || return 1
@@ -340,6 +345,19 @@ merge_outcome_dispatch_squash() (
printf '%s\n' "$payload" | pr_gh_plain api graphql --hostname "$MERGE_REPO_HOST" --input -
)
merge_outcome_dispatch_prior_ci_squash() (
local payload
payload=$(printf '%s\n%s\n' "$MERGE_OUTCOME_RECORD" "$1" | jq -cse --arg title "$2" '
.[1] as $body | .[0] |
select(.phase == "intent" and .accepted == false and .method == "squash" and
.route == "admin" and .priorCiAdmin.head == .head) |
{sha:.head,merge_method:"squash",commit_message:($body.base64 | @base64d),commit_title:$title}
') || return 1
printf '%s\n' "$payload" | pr_gh_plain api --hostname "$MERGE_REPO_HOST" \
"repos/$MERGE_REPO_NAME/pulls/$(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -r .pr)/merge" \
--method PUT --input -
)
merge_read() {
local mode="$1" pr="$2" repo="${3:-${MERGE_REPO_URL:-}}" first="${MERGE_TRANSPORT:-rest}" second response status query checks_err checks_error
if [ "$first" = rest ]; then second=graphql; else second=rest; fi
@@ -448,7 +466,7 @@ merge_outcome_stable() {
}
# Ordinary admission pins the head; GitHub applies it to the current base. Keep
# the main used for local tree proof and intent while rechecking every PR fact.
if [ "${MERGE_ADMISSION_ACTIVE:-false}" = true ] && [ "${MERGE_USE_CRABBOX_ADMIN_BYPASS:-false}" = false ]; then
if [ "${MERGE_ADMISSION_ACTIVE:-false}" = true ] && [ "${MERGE_USE_CRABBOX_ADMIN_BYPASS:-false}" = false ] && [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = false ]; then
reread=$(printf '%s\n' "$reread" | jq -c --arg main "$(printf '%s\n' "$MERGE_OBSERVATION" | jq -r .main)" '.main=$main') || return 1
fi
[ "$reread" = "$MERGE_OBSERVATION" ] && return 0
@@ -632,7 +650,13 @@ merge_outcome_comment_body() {
method=$(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -r .method) || return 1
route=$(printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -r .route) || return 1
case "$route:$method" in
admin:*) label="admin squash with trusted Crabbox infrastructure proof" ;;
admin:*)
if printf '%s\n' "$MERGE_OUTCOME_RECORD" | jq -e 'has("priorCiAdmin")' >/dev/null; then
label="explicitly authorized admin squash with prior CI and scoped validation"
else
label="admin squash with trusted Crabbox infrastructure proof"
fi
;;
queue:*) label="merge queue (requested $method)" ;;
auto:*) label="squash auto-merge" ;;
immediate:merge) label="merge commit" ;;
+298
View File
@@ -0,0 +1,298 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { lstatSync, readFileSync } from "node:fs";
import { isDirectRunUrl } from "../lib/direct-run.mjs";
import { parseGithubResponse } from "./gh-api-preflight.mjs";
import { execPrGh, execPrGhJson } from "./github.mjs";
import { readMergePolicy, readRequiredMergeChecks } from "./merge-rest.mjs";
const oid = /^[0-9a-f]{40}$/;
const positiveInteger = (value) => Number.isSafeInteger(value) && value > 0;
const nonempty = (value) => typeof value === "string" && value.trim().length > 0;
const digest = (value) => createHash("sha256").update(value).digest("hex");
function requireEvidence(condition, message) {
if (!condition) {
throw new Error(`Prior-CI admin admission: ${message}`);
}
}
function priorCiDelta(priorHead, head) {
requireEvidence(oid.test(priorHead) && oid.test(head), "full commit IDs are required");
const git = (args) =>
execFileSync(process.env.OPENCLAW_PR_GIT || "git", args, {
env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
maxBuffer: 32 * 1024 * 1024,
});
for (const commit of [priorHead, head]) {
git(["cat-file", "-e", `${commit}^{commit}`]);
}
const delta = git(["diff", "--raw", "--abbrev=40", "--no-renames", "-z", priorHead, head, "--"]);
const changedPaths = git(["diff", "--name-only", "--no-renames", "-z", priorHead, head, "--"])
.toString("utf8")
.split("\0")
.filter(Boolean);
return { priorHead, head, deltaSha256: digest(delta), changedPaths };
}
function readEvidence(path, repository, pr, head) {
requireEvidence(
lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink(),
"evidence must be a regular file",
);
const bytes = readFileSync(path);
const value = JSON.parse(bytes);
requireEvidence(
value.version === 1 &&
value.changeKind === "conflict-resolution" &&
value.repository === repository &&
value.pr === pr &&
value.head === head,
"evidence must attest conflict-resolution changes and bind this repository, PR, and prepared head",
);
requireEvidence(
oid.test(value.priorHead) && positiveInteger(value.runId) && positiveInteger(value.runAttempt),
"evidence must pin a prior CI head, run, and attempt",
);
requireEvidence(
nonempty(value.reason) &&
Array.isArray(value.contracts) &&
value.contracts.length > 0 &&
value.contracts.every(nonempty),
"operator reason and affected contracts are required",
);
requireEvidence(
Array.isArray(value.checks) &&
value.checks.length > 0 &&
value.checks.every(
(check) =>
check && nonempty(check.command) && check.result === "passed" && nonempty(check.evidence),
),
"scoped passing commands and their evidence are required; these remain operator attestations",
);
const delta = priorCiDelta(value.priorHead, head);
requireEvidence(
value.deltaSha256 === delta.deltaSha256,
"prior-to-prepared delta changed; inspect and validate it again",
);
return { ...value, ...delta, evidenceSha256: digest(bytes) };
}
function verifyPriorCiAdmin({ evidencePath, repository, pr, head, actor }) {
const evidence = readEvidence(evidencePath, repository, pr, head);
const repo = {
nameWithOwner: repository,
host: "github.com",
url: `https://github.com/${repository}`,
};
const apiArgs = (endpoint, paginate = false) => [
"api",
"--hostname",
repo.host,
endpoint,
"-H",
"Cache-Control: max-age=0",
...(paginate ? ["--paginate", "--slurp"] : []),
];
const read = (endpoint, paginate = false) =>
execPrGhJson(apiArgs(endpoint, paginate), {}, "plain");
const writerRead = (endpoint) => {
const response = parseGithubResponse(
execPrGh([...apiArgs(endpoint), "--include"], { encoding: "utf8" }, "plain"),
);
requireEvidence(response.status === "200", "writer authority is unavailable");
return response.body;
};
const authority = writerRead(`repos/${repository}`);
requireEvidence(
authority?.full_name === repository &&
authority.permissions?.admin === true &&
authority.owner?.type === "Organization",
"writer must administer the target organization repository",
);
const membership = writerRead(
`orgs/${repository.split("/")[0]}/memberships/${encodeURIComponent(actor)}`,
);
requireEvidence(
membership?.state === "active" &&
membership.role === "admin" &&
membership.user?.login === actor,
"writer must be an active organization admin",
);
const policy = readMergePolicy(repo);
const reviewRules = policy.rules.filter((rule) => rule.type === "pull_request");
let requireReviews = false;
let requireThreads = false;
for (const rule of reviewRules) {
const parameters = rule.parameters;
requireEvidence(
parameters &&
Number.isSafeInteger(parameters.required_approving_review_count) &&
parameters.required_approving_review_count >= 0 &&
typeof parameters.require_code_owner_review === "boolean" &&
typeof parameters.require_last_push_approval === "boolean" &&
typeof parameters.required_review_thread_resolution === "boolean",
"effective review requirements are incomplete",
);
requireReviews ||=
parameters.required_approving_review_count > 0 ||
parameters.require_code_owner_review ||
parameters.require_last_push_approval;
requireThreads ||= parameters.required_review_thread_resolution;
}
const query =
"query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid reviewDecision reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}}}}}";
const reviewResponse = parseGithubResponse(
execPrGh(
[
"api",
"graphql",
"--hostname",
repo.host,
"--include",
"-H",
"Cache-Control: max-age=0",
"-f",
`owner=${repository.split("/")[0]}`,
"-f",
`name=${repository.split("/")[1]}`,
"-F",
`number=${pr}`,
"-f",
`query=${query}`,
],
{ encoding: "utf8" },
"plain",
),
);
const review = reviewResponse.body?.data?.repository?.pullRequest;
requireEvidence(
reviewResponse.status === "200" &&
!reviewResponse.body.errors &&
review?.headRefOid === head &&
(review.reviewDecision === "APPROVED" || (!requireReviews && review.reviewDecision === null)),
"current enforced reviews must be satisfied; admin CI authority does not waive reviews",
);
if (requireThreads) {
requireEvidence(
review.reviewThreads?.pageInfo?.hasNextPage === false &&
Array.isArray(review.reviewThreads.nodes) &&
review.reviewThreads.nodes.every((thread) => thread.isResolved === true),
"required review threads must all be resolved (more than 100 threads require ordinary landing)",
);
}
const run = read(
`repos/${repository}/actions/runs/${evidence.runId}/attempts/${evidence.runAttempt}`,
);
let samePullRequest =
Array.isArray(run?.pull_requests) &&
run.pull_requests.some(
(pull) =>
pull.number === pr &&
pull.head?.sha === evidence.priorHead &&
pull.base?.repo?.id === authority.id,
);
if (run?.event === "workflow_dispatch") {
const pull = writerRead(`repos/${repository}/pulls/${pr}`);
let ancestor = false;
try {
execFileSync(
process.env.OPENCLAW_PR_GIT || "git",
["merge-base", "--is-ancestor", evidence.priorHead, head],
{ env: { ...process.env, GIT_NO_LAZY_FETCH: "1" }, stdio: "pipe" },
);
ancestor = true;
} catch {
/* An unretained or rewritten prior head cannot supply ancestry proof. */
}
samePullRequest =
ancestor &&
pull?.number === pr &&
pull.head?.sha === head &&
pull.base?.ref === "main" &&
pull.base?.repo?.id === authority.id &&
pull.head?.repo?.id === authority.id &&
run.head_branch === pull.head?.ref &&
run.head_repository?.full_name === repository;
}
requireEvidence(
run?.id === evidence.runId &&
run.run_attempt === evidence.runAttempt &&
run.head_sha === evidence.priorHead &&
run.repository?.full_name === repository &&
/^\.github\/workflows\/ci\.yml(?:@.+)?$/u.test(run.path ?? "") &&
["pull_request", "workflow_dispatch"].includes(run.event) &&
run.status === "completed" &&
run.conclusion === "success" &&
samePullRequest,
"selected successful CI attempt must belong to this PR and prior head",
);
const pages = read(
`repos/${repository}/actions/runs/${evidence.runId}/attempts/${evidence.runAttempt}/jobs?per_page=100`,
true,
);
requireEvidence(
Array.isArray(pages) &&
pages.length > 0 &&
pages.every((page) => Array.isArray(page.jobs)) &&
pages.flatMap((page) => page.jobs).length === pages[0].total_count &&
pages.every((page) => page.total_count === pages[0].total_count),
"prior CI job evidence is incomplete",
);
const gate = pages.flatMap((page) => page.jobs).filter((job) => job.name === "openclaw/ci-gate");
requireEvidence(
gate.length === 1 &&
gate[0].status === "completed" &&
gate[0].conclusion === "success" &&
gate[0].head_sha === evidence.priorHead &&
gate[0].run_id === evidence.runId,
"selected prior attempt must contain a successful CI gate for its exact head",
);
const checks = readRequiredMergeChecks(repo, head, policy);
requireEvidence(
Array.isArray(checks) &&
checks.some((check) => check.name === "openclaw/ci-gate") &&
checks.every(
(check) =>
check.bucket === "pass" ||
(check.name === "openclaw/ci-gate" && ["pending", "skipping"].includes(check.bucket)),
),
"only pending/skipped normal CI may be waived; failed CI and other checks, including security, remain blocking",
);
requireEvidence(
digest(readFileSync(evidencePath)) === evidence.evidenceSha256,
"operator evidence changed while reading authority",
);
return {
...evidence,
actor,
dispatchTransport: "rest",
ciUrl: `${repo.url}/actions/runs/${evidence.runId}/attempts/${evidence.runAttempt}`,
policySha256: digest(JSON.stringify(policy)),
};
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
try {
const [mode, ...args] = process.argv.slice(2);
const result =
mode === "delta"
? priorCiDelta(...args)
: mode === "verify"
? verifyPriorCiAdmin({
evidencePath: args[0],
repository: args[1],
pr: Number(args[2]),
head: args[3],
actor: args[4],
})
: (() => {
throw new Error(
"Expected delta <prior-head> <head> or verify <evidence> <repo> <PR> <head> <actor>",
);
})();
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+5 -6
View File
@@ -98,7 +98,7 @@ function pageArrays(pages) {
return pages.flat();
}
function readPolicy(repo) {
export function readMergePolicy(repo) {
let response;
try {
response = execPrGh(
@@ -237,7 +237,7 @@ function beginRead(repo, pr, observe) {
receipt || authority.permissions?.admin === true,
"policy-reader admin access changed",
);
const policy = receipt ? null : readPolicy(repo);
const policy = receipt ? null : readMergePolicy(repo);
return { authority, main: mainSha, record, policy };
}
@@ -383,9 +383,9 @@ function latestRequiredChecks(repo, head, checks) {
return [...unique, ...groups.values()];
}
function requiredChecks(repo, snapshot) {
export function readRequiredMergeChecks(repo, head, policy) {
const requirements = new Map();
for (const rule of snapshot.policy.rules) {
for (const rule of policy.rules) {
if (rule.type !== "required_status_checks") {
continue;
}
@@ -402,7 +402,6 @@ function requiredChecks(repo, snapshot) {
required.some(
({ context, app }) => check.name === context && (app === null || check.app.id === app),
);
const head = snapshot.record.head.sha;
const contexts = new Set(required.map(({ context }) => context));
const nameFilter =
contexts.size === 1 ? `&check_name=${encodeURIComponent(required[0].context)}` : "";
@@ -566,7 +565,7 @@ function main([mode, repository, prValue, head, bodySnapshot, expectedObservatio
const snapshot = beginRead(repo, pr, observing);
const checks =
mode === "checks" || ((observing || mode === "merge") && snapshot.record.state === "open")
? requiredChecks(repo, snapshot)
? readRequiredMergeChecks(repo, snapshot.record.head.sha, snapshot.policy)
: undefined;
if (mode !== "checks" && checks !== undefined) {
requireEvidence(
+70 -17
View File
@@ -43,7 +43,7 @@ record_crabbox_landing_parent_audit() {
fi
if ! pr_gh_plain api "repos/$MERGE_REPO_NAME/commits/$landed_sha" >"$commit_file"; then
rm -f "$audit_tmp"
echo "Crabbox landing parent audit failed after merge: unable to read landed commit $landed_sha." >&2
echo "Admin landing parent audit failed after merge: unable to read landed commit $landed_sha." >&2
return 1
fi
@@ -55,7 +55,7 @@ record_crabbox_landing_parent_audit() {
| select(type == "string" and test("^[0-9a-f]{40}$"))
' "$commit_file") || {
rm -f "$audit_tmp"
echo "Crabbox landing parent audit failed after merge: landed commit has no valid first parent." >&2
echo "Admin landing parent audit failed after merge: landed commit has no valid first parent." >&2
return 1
}
@@ -81,9 +81,9 @@ record_crabbox_landing_parent_audit() {
fi
if [ "$status" = "match" ]; then
echo "Crabbox landing parent audit matched: landed=$landed_sha parent=$actual_parent_sha"
echo "Admin landing parent audit matched: landed=$landed_sha parent=$actual_parent_sha"
else
echo "Crabbox landing parent audit drift: landed=$landed_sha expected_parent=$expected_parent_sha actual_parent=$actual_parent_sha"
echo "Admin landing parent audit drift: landed=$landed_sha expected_parent=$expected_parent_sha actual_parent=$actual_parent_sha"
echo "The merge already completed after intervening main movement; this audit reports the residual non-atomic race."
fi
}
@@ -93,6 +93,18 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/crabbox-merge-bypass.sh"
# shellcheck source=scripts/pr-lib/merge-outcome.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/merge-outcome.sh"
verify_prior_ci_admin() {
local pr="$1" head="$2" actor proof
[ "${MERGE_REPO_HOST:-}" = github.com ] || { echo "Prior-CI admin admission currently requires github.com." >&2; return 1; }
actor=$(pr_gh_writer_login "$MERGE_REPO_HOST") || return 1
proof=$(node "$script_parent_dir/pr-lib/merge-prior-ci.mjs" verify "$MERGE_ADMIN_EVIDENCE" "$MERGE_REPO_NAME" "$pr" "$head" "$actor") || return 1
if [ -n "$MERGE_PRIOR_CI_PROOF" ] && [ "$MERGE_PRIOR_CI_PROOF" != "$proof" ]; then
echo "Prior-CI admin evidence or authority changed during admission; no merge requested." >&2
return 1
fi
MERGE_PRIOR_CI_PROOF="$proof"
}
fetch_clawsweeper_review_comments() {
local pr="$1" repo_name="$2" repo_host="$3"
if ! CLAWSWEEPER_REVIEW_COMMENTS=$(pr_gh_plain issue-comments "$repo_name" "$repo_host" "$pr"); then
@@ -230,7 +242,12 @@ merge_verify() {
source .local/prep.env || return 1
if [ "${GATES_MODE:-}" = remote_crabbox_aws ]; then MERGE_TRANSPORT=graphql; fi
local github_pending=false
if [ "${GATES_MODE:-}" = github_pending ]; then
if [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = true ] &&
{ [ "${GATES_MODE:-}" != github_pending ] || [ "${HOSTED_GATES_TARGET_HEAD_SHA:-}" != "$PREP_HEAD_SHA" ]; }; then
echo "Prior-CI admin admission requires exact-head github_pending preparation." >&2
return 1
fi
if [ "${GATES_MODE:-}" = github_pending ] && [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" != true ]; then
if { [ "$qualified_refusal" != true ] && { [ "$auto_merge_requested" != true ] || [ -n "$replacement_head" ]; }; } ||
[ "${HOSTED_GATES_TARGET_HEAD_SHA:-}" != "$PREP_HEAD_SHA" ] ||
[ "${MERGE_TRANSPORT:-graphql}" != graphql ]; then
@@ -280,7 +297,9 @@ merge_verify() {
require_clawsweeper_review "$pr" "$pr_head_sha" \
"${MERGE_REPO_NAME:-}" "${MERGE_REPO_HOST:-}" || return 1
mark_pr_operation_side_effects_started || return 1
if [ "$github_pending" = true ]; then
if [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = true ]; then
verify_prior_ci_admin "$pr" "$PREP_HEAD_SHA" || return 1
elif [ "$github_pending" = true ]; then
echo "GitHub will enforce required checks; submitting without a CI watcher."
elif [ "${GATES_MODE:-}" = "hosted_exact_or_recent_parent" ]; then
# The stamp selects the owner, not proof. Revalidate before skipping the
@@ -353,12 +372,17 @@ merge_verify() {
local pending_required
pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length') || return 1
if [ "$pending_required" -gt 0 ] && { [ "$github_pending" != true ] || [ "$qualified_refusal" = true ]; }; then
if [ "$pending_required" -gt 0 ] && [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" != true ] && { [ "$github_pending" != true ] || [ "$qualified_refusal" = true ]; }; then
echo "Required checks are still pending."
exit 1
fi
if [ "$failed_required" -gt 0 ]; then
if [ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = true ]; then
# The independent verifier permits only pending/skipped normal CI, never a
# failed check. Repeat against the merge owner's current check observation.
printf '%s\n' "$checks_json" | jq -e 'all(.[]; .bucket == "pass" or
(.name == "openclaw/ci-gate" and (.bucket == "pending" or .bucket == "skipping")))' >/dev/null || return 1
elif [ "$failed_required" -gt 0 ]; then
if [ "$github_pending" = true ]; then
echo "Required checks are failing; fix them before requesting auto-merge." >&2
return 1
@@ -544,6 +568,15 @@ merge_run() {
local cancel_auto="${7:-false}"
local refusal_directory="${8:-}" refusal="" qualified_refusal=false
local MERGE_REFUSAL_DIRECTORY=""
local MERGE_ADMIN_EVIDENCE="${9:-}" confirmed_admin="${10:-false}" MERGE_PRIOR_CI_PROOF=""
local MERGE_USE_PRIOR_CI_ADMIN=false
if [ -n "$MERGE_ADMIN_EVIDENCE" ] || [ "$confirmed_admin" = true ]; then
[ -n "$MERGE_ADMIN_EVIDENCE" ] && [ "$confirmed_admin" = true ] && [ "$auto_merge_requested" = false ] &&
[ -z "$recovery_oid$replacement_head$legacy_directory$refusal_directory" ] && [ "$cancel_auto" = false ] &&
[ "${OPENCLAW_PR_MERGE_METHOD:-squash}" = squash ] || return 2
MERGE_ADMIN_EVIDENCE=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$MERGE_ADMIN_EVIDENCE") || return 1
MERGE_USE_PRIOR_CI_ADMIN=true
fi
[ -z "$refusal_directory" ] || refusal_directory=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$refusal_directory") || return 1
[ -z "$body_path" ] || body_path=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$body_path") || return 1
if [ -n "$replacement_head" ] &&
@@ -581,7 +614,7 @@ merge_run() {
merge_outcome_resume "$pr"
return
fi
if [ "$auto_merge_requested" = true ] || [ "${OPENCLAW_PR_MERGE_METHOD:-squash}" != squash ]; then
if [ "$auto_merge_requested" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ] || [ "${OPENCLAW_PR_MERGE_METHOD:-squash}" != squash ]; then
MERGE_TRANSPORT=graphql
fi
review_artifact_preflight "$pr" prepared || return 1
@@ -747,7 +780,7 @@ merge_run() {
for admission_attempt in 1 2 3; do
merge_outcome_observe "$pr" || return 1
if [ "$MERGE_TRANSPORT" = rest ] &&
{ [ "$merge_method" != squash ] || [ "$auto_merge_requested" = true ] || [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ]; }; then
{ [ "$merge_method" != squash ] || [ "$auto_merge_requested" = true ] || [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; }; then
merge_outcome_stop "REST fallback supports ordinary immediate squash only; auto, queue, and admin routes require GraphQL"
return 1
fi
@@ -765,9 +798,9 @@ merge_run() {
merge_outcome_stop "require OPEN, exact prepared head, main base, non-draft, no conflicts, and no existing auto/queue request; inspect current PR state"
return 1
fi
if [ -n "$previous_observation" ] && ! printf '%s\n' "$MERGE_OBSERVATION" | jq -e --argjson previous "$previous_observation" --argjson admin "$MERGE_USE_CRABBOX_ADMIN_BYPASS" '
if [ -n "$previous_observation" ] && ! printf '%s\n' "$MERGE_OBSERVATION" | jq -e --argjson previous "$previous_observation" --argjson admin "$MERGE_USE_CRABBOX_ADMIN_BYPASS" --argjson priorCi "$MERGE_USE_PRIOR_CI_ADMIN" '
def facts: del(.pr.mergeable,.pr.mergeStateStatus) |
if $admin then . else del(.main) end;
if $admin or $priorCi then . else del(.main) end;
(if .transport != $previous.transport then
(facts | del(.transport,.restPolicy)) == ($previous | facts | del(.transport,.restPolicy))
else facts == ($previous | facts) end) and
@@ -815,7 +848,7 @@ merge_run() {
fi
merge_body_snapshot=$(snapshot_merge_body "$merge_body_file") || return 1
fi
if [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ]; then
if [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
route="admin"
merge_args=(--admin "${merge_args[@]}")
elif [ "$(printf '%s\n' "$MERGE_OBSERVATION" | jq -r .pr.isMergeQueueEnabled)" = true ]; then
@@ -881,7 +914,7 @@ merge_run() {
if [ "$route" != immediate ] || [ "$merge_method" != squash ]; then
merge_outcome_stable "$pr" || return 1
fi
if [ "$route" = admin ]; then
if [ "$route" = admin ] && [ "$MERGE_USE_PRIOR_CI_ADMIN" != true ]; then
verify_crabbox_admin_merge_bypass "$pr" "$PREP_HEAD_SHA" || return 1
crabbox_final_main_sha=$(jq -er '.mainSha | select(type == "string" and test("^[0-9a-f]{40}$"))' .local/merge-crabbox-bypass.json) || return 1
[ "$crabbox_final_main_sha" = "$observed_main" ] || {
@@ -914,7 +947,7 @@ merge_run() {
# Revalidate the selected route before retaining any REST mutation intent.
if [ "$MERGE_TRANSPORT" = rest ]; then
if [ "$merge_method" != squash ] || [ "$route" != immediate ] ||
[ "$auto_merge_requested" = true ] || [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ]; then
[ "$auto_merge_requested" = true ] || [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
merge_outcome_stop "REST fallback supports ordinary immediate squash only; auto, queue, and admin routes require GraphQL"
return 1
fi
@@ -940,6 +973,18 @@ merge_run() {
[ "$correction_gates_oid" = "$(pr_git hash-object --no-filters .local/gates.env)" ] || return 1
require_correction_publication_gates "$pr" "$(pr_git rev-parse HEAD)" || return 1
fi
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
verify_prior_ci_admin "$pr" "$PREP_HEAD_SHA" || return 1
merge_outcome_stable "$pr" || return 1
# No awaited operation may replace the operator's bytes after validation.
node --input-type=module -e '
import { readFileSync, lstatSync } from "node:fs";
import { createHash } from "node:crypto";
const [path, expected] = process.argv.slice(1);
if (!lstatSync(path).isFile() || createHash("sha256").update(readFileSync(path)).digest("hex") !== expected) process.exit(1);
' "$MERGE_ADMIN_EVIDENCE" "$(printf '%s\n' "$MERGE_PRIOR_CI_PROOF" | jq -r .evidenceSha256)" || return 1
crabbox_final_main_sha="$observed_main"
fi
local intent attempt
attempt=$(node -e 'process.stdout.write(require("node:crypto").randomUUID())') || return 1
intent=$(printf '%s\n' "$MERGE_OBSERVATION" | jq -c --argjson repo "$MERGE_REPO" \
@@ -951,6 +996,9 @@ merge_run() {
main:.main,method:$method,route:$route,attempt:$attempt,phase:"intent",accepted:false,landed:null,
clawsweeperReview:$review} + (if $transport == "rest" then {transport:"rest"} else {} end)
') || return 1
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
intent=$(printf '%s\n' "$intent" | jq -c --argjson proof "$MERGE_PRIOR_CI_PROOF" '.priorCiAdmin=$proof') || return 1
fi
if [ -n "$legacy_directory" ]; then
intent=$(printf '%s\n' "$intent" | jq -c --argjson legacy "$legacy_refusal" --arg actor "$recovery_actor" '.legacyRefusal=($legacy + {actor:$actor})') || return 1
elif [ -n "$recovery_oid" ]; then
@@ -980,7 +1028,9 @@ merge_run() {
exec >"$merge_output" || exit 125
exec 2>&1
export OCTOPOOL_DIAGNOSTICS=1
if [ "$MERGE_TRANSPORT" = rest ]; then
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
merge_outcome_dispatch_prior_ci_squash "$merge_body_snapshot" "$MERGE_SUBJECT"
elif [ "$MERGE_TRANSPORT" = rest ]; then
merge_rest merge "$pr" "$PREP_HEAD_SHA" "$merge_body_snapshot" "$MERGE_OBSERVATION"
elif [ "$route" = immediate ] && [ "$merge_method" = squash ]; then
merge_outcome_dispatch_squash "$merge_body_snapshot"
@@ -1002,7 +1052,10 @@ merge_run() {
fi
local comment_body MERGE_COMPLETION_COMMENT_URL
comment_body=$(merge_outcome_comment_body "$pr") || return 1
if [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = "true" ]; then
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
printf -v comment_body '%s\n- Prior successful CI: %s\n- Subsequent conflict changes: reviewed at `%s`; scoped validation retained as operator evidence. No current-head CI success is claimed.' \
"$comment_body" "$(printf '%s\n' "$MERGE_PRIOR_CI_PROOF" | jq -r .ciUrl)" "$PREP_HEAD_SHA"
elif [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = "true" ]; then
local crabbox_check_url
local ci_gate_url
crabbox_check_url=$(jq -r .crabboxCheckUrl .local/merge-crabbox-bypass.json)
+7 -23
View File
@@ -3,9 +3,10 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures";
import { expectDefined } from "@openclaw/normalization-core/expect";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { bundledPluginFile } from "../plugin-sdk/test-helpers/bundled-plugin-paths.js";
const {
detectChangedScope,
@@ -16,7 +17,6 @@ const {
parseArgs,
shouldRunIosScreenshots,
shouldRunNativeI18n,
writeGitHubOutput,
} = await import("../../scripts/ci-changed-scope.mjs");
const markerPaths: string[] = [];
@@ -704,25 +704,6 @@ describe("detectChangedScope", () => {
expect(listChangedPaths(base, "HEAD", repoDir).toSorted()).toEqual(changedPaths.toSorted());
});
it("drops oversized changed-path payloads before workflow environment interpolation", () => {
const outputPath = path.join(os.tmpdir(), `openclaw-ci-scope-output-${Date.now()}.txt`);
markerPaths.push(outputPath);
const changedPaths = Array.from(
{ length: 1_000 },
(_, index) => `src/generated/${index}-${"x".repeat(100)}.ts`,
);
writeGitHubOutput(
detectChangedScope(["docs/ci.md"]),
outputPath,
undefined,
undefined,
false,
changedPaths,
);
expect(parseGitHubOutput(fs.readFileSync(outputPath, "utf8")).changed_paths_json).toBe("null");
});
it.each<[string, string, string, boolean, string[]?]>([
["missing base", "", "missing", true, ["--head", "HEAD"]],
["unknown option", "", "missing", true, ["--base", "HEAD", "--head", "HEAD", "--mystery"]],
@@ -790,16 +771,19 @@ describe("detectChangedScope", () => {
);
}
expect(Object.keys(output).toSorted()).toEqual(
"changed_paths_json node_test_data_only run_android run_changed_smoke run_control_ui_i18n run_fast_install_smoke run_full_install_smoke run_ios_build run_ios_screenshots run_macos run_macos_node run_native_i18n run_node run_node_fast_ci_routing run_node_fast_only run_node_fast_plugin_contracts run_skills_python run_ui_tests run_windows strict_control_ui_i18n strict_native_i18n".split(
"changed_paths_file changed_paths_json node_test_data_only run_android run_changed_smoke run_control_ui_i18n run_fast_install_smoke run_full_install_smoke run_ios_build run_ios_screenshots run_macos run_macos_node run_native_i18n run_node run_node_fast_ci_routing run_node_fast_only run_node_fast_plugin_contracts run_skills_python run_ui_tests run_windows strict_control_ui_i18n strict_native_i18n".split(
" ",
),
);
expect(output.changed_paths_json).toBe(
failSafe ? "null" : JSON.stringify(changedPath ? [changedPath] : []),
);
expect(
fs.readFileSync(expectDefined(output.changed_paths_file, "changed-path manifest"), "utf8"),
).toBe(output.changed_paths_json);
expect(output.node_test_data_only).toBe("false");
for (const [key, value] of Object.entries(output)) {
if (key !== "changed_paths_json" && key !== "node_test_data_only") {
if (!key.startsWith("changed_paths_") && key !== "node_test_data_only") {
const selected =
(failSafe && !key.startsWith("run_node_fast")) ||
(key === "run_node" && Boolean(changedPath)) ||
+66
View File
@@ -779,6 +779,72 @@ function runControlUiI18nSourceFixture(options: {
rmSync(root, { force: true, recursive: true });
}
}
describe("changed-path transport", () => {
it("plans current PR tests from the complete manifest above the output size limit", () => {
const changedPaths = [
...Array.from(
{ length: 1_000 },
(_, index) => `docs/generated/${index}-${"x".repeat(100)}.md`,
),
"src/focused.ts",
];
const outputs = runCiChangedScopeFixture(changedPaths);
const manifestStep = readCiWorkflow().jobs.preflight.steps.find(
(step: WorkflowStep) => step.name === "Build CI manifest",
);
const scopeEnv = Object.fromEntries(
Object.entries(manifestStep.env)
.filter(([key]) => key.startsWith("OPENCLAW_CI_CHANGED_PATHS_"))
.map(([key, value]) => [
key,
String(
evaluateWorkflowExpression(value, {
eventName: "pull_request",
repository: "openclaw/openclaw",
runAttempt: 1,
steps: { changed_scope: { outputs } },
}),
),
]),
);
expect(Buffer.byteLength(JSON.stringify(changedPaths))).toBeGreaterThan(64 * 1024);
expect(outputs.changed_paths_json).toBe("null");
const manifest = runCiManifestFixture({
bundledPlanner: true,
eventName: "pull_request",
scopeEnv,
});
expect(manifest.status, manifest.output).toBe(0);
expect(
JSON.parse(expectDefined(manifest.outputs.checks_node_core_nondist_matrix, "Node matrix"))
.include,
).toEqual([
expect.objectContaining({
check_name: "changed-node-plan",
targets: ["src/focused.test.ts"],
}),
]);
expect(
JSON.parse(readFileSync(expectDefined(outputs.changed_paths_file, "manifest file"), "utf8")),
).toEqual(changedPaths);
});
it.each([undefined, "{", "[42]"])("rejects an unusable manifest file: %s", (contents) => {
const manifestPath = path.join(tempDirs.make("openclaw-ci-paths-"), "paths.json");
if (contents !== undefined) {
writeFileSync(manifestPath, contents);
}
const manifest = runCiManifestFixture({
bundledPlanner: true,
eventName: "pull_request",
changedPaths: ["src/focused.ts"],
scopeEnv: { OPENCLAW_CI_CHANGED_PATHS_FILE: manifestPath },
});
expect(manifest.status).not.toBe(0);
expect(manifest.output).toContain("Current PR CI requires complete changed paths");
});
});
describe("release fast lane", () => {
const scopeEnv = {
OPENCLAW_CI_RELEASE_FAST_LANE_LABEL: "true",
+59 -5
View File
@@ -227,6 +227,23 @@ export function createMergeOutcomeFixtureHarness() {
crash: "",
comment: "success",
admin: false,
priorCi: {
enabled: false,
head: sourceCommits[0]!,
runHead: sourceCommits[0]!,
event: "workflow_dispatch",
branch: "topic",
workflowPath: ".github/workflows/ci.yml",
missingCheck: "",
reviewDecision: "APPROVED" as string | null,
reviewCount: 1,
requireThreads: false,
resolved: true,
membership: "admin",
evidencePath: "",
mutateEvidence: false,
otherCheck: "",
},
audit: false,
gates: "pass",
requiredCheckName: "CI",
@@ -332,14 +349,16 @@ const restCheckRuns=()=>{
const check={id:1,head_sha:s.pr.headRefOid,name:s.restContexts[0],status:"completed",conclusion:s.gates==="pass"?"success":"failure",
started_at:"2026-09-20T00:00:00Z",check_suite:{id:10},app:{id:s.restCheckApp,slug:s.restCheckApp===15368?"github-actions":"custom-ci"}};
if(s.restUnseenSuite==="partial-pending") return [check,{...check,id:2,name:"detect-changes",check_suite:{id:2}}];
if(!s.restDuplicate) return s.restContexts.map((name,index)=>({...check,id:index+1,name,check_suite:{id:10+index},
conclusion:name===s.restFailedContext?"failure":check.conclusion}));
if(!s.restDuplicate) return s.restContexts.filter(name=>name!==s.priorCi.missingCheck).map((name,index)=>({...check,id:index+1,name,check_suite:{id:10+index},
...(s.priorCi.enabled?{status:name===s.requiredCheckName&&s.gates==="pending"?"in_progress":"completed",
conclusion:name===s.restFailedContext?"failure":name!==s.requiredCheckName?"success":s.gates==="pending"?null:s.gates==="pass"?"success":"failure"}:
{conclusion:name===s.restFailedContext?"failure":check.conclusion})}));
return [{...check,conclusion:"failure"},{...check,id:2,check_suite:{id:20},
started_at:s.restDuplicate==="missing-time"?null:s.restDuplicate==="same-time"?check.started_at:"2026-09-20T00:01:00Z"}];
};
const restCheckSuites=()=>{
const suites=restCheckRuns().map(check=>{
const running=s.restSuite==="rerunning"||(s.restUnseenSuite==="partial-pending"&&check.check_suite.id===2);
const running=check.status!=="completed"||s.restSuite==="rerunning"||(s.restUnseenSuite==="partial-pending"&&check.check_suite.id===2);
return {id:check.check_suite.id,head_sha:s.pr.headRefOid,app:check.app,
status:running?"in_progress":"completed",conclusion:running?null:check.conclusion};
});
@@ -381,6 +400,17 @@ else if(args[0]==="api"&&args.some(arg=>new RegExp("^repos/[^/]+/[^/]+$").test(a
}
out(args.includes("--include")?"HTTP/2.0 200 OK\\n\\n"+JSON.stringify(s.repoAuthority):s.repoAuthority);
}
else if(args[0]==="api"&&args.some(arg=>arg.startsWith("orgs/fixture/memberships/"))) {
out("HTTP/2.0 200 OK\\n\\n"+JSON.stringify({state:"active",role:s.priorCi.membership,user:{login:s.operator}}));
}
else if(args[0]==="api"&&args.some(arg=>arg.startsWith("repos/fixture/repo/actions/runs/501/attempts/2"))) {
if(args.some(arg=>arg.includes("/jobs?"))) out([{total_count:1,jobs:[{name:"openclaw/ci-gate",status:"completed",conclusion:"success",head_sha:s.priorCi.runHead,run_id:501}]}]);
else out({id:501,run_attempt:2,head_sha:s.priorCi.runHead,repository:{full_name:s.repo.nameWithOwner},path:s.priorCi.workflowPath,event:s.priorCi.event,head_branch:s.priorCi.branch,head_repository:{full_name:s.repo.nameWithOwner},status:"completed",conclusion:"success",pull_requests:s.priorCi.event==="pull_request"?[{number:123,head:{sha:s.priorCi.runHead},base:{repo:{id:s.repoAuthority.id}}}]:[]});
}
else if(args.includes("graphql")&&args.some(arg=>arg.includes("reviewThreads("))) {
out({data:{repository:{pullRequest:{headRefOid:s.pr.headRefOid,reviewDecision:s.priorCi.reviewDecision,reviewThreads:{nodes:[{isResolved:s.priorCi.resolved}],pageInfo:{hasNextPage:false}}}}}});
if(s.priorCi.mutateEvidence) fs.appendFileSync(s.priorCi.evidencePath," ");
}
else if(args[0]==="api"&&args.includes("user")) {
if(route==="direct"&&JSON.stringify(args)===JSON.stringify(["api","--hostname","github.com","user","--include"])) out("HTTP/2.0 200 OK\\n\\n"+JSON.stringify({login:s.operator}));
else out("relay-reader");
@@ -434,6 +464,7 @@ else if(args[0]==="api"&&args.includes("repos/fixture/repo/branches/main/protect
else if(args[0]==="api"&&args.some(arg=>arg.startsWith("repos/fixture/repo/rules/branches/main?"))) {
out(s.restPolicy==="missing"?[null]:[[
{type:"required_status_checks",parameters:{required_status_checks:s.restContexts.map(context=>({context,integration_id:s.restRequiredApp}))}},
...(s.priorCi.enabled?[{type:"pull_request",parameters:{required_approving_review_count:s.priorCi.reviewCount,require_code_owner_review:false,require_last_push_approval:false,required_review_thread_resolution:s.priorCi.requireThreads}}]:[]),
...(s.restPolicy==="queue"?[{type:"merge_queue"}]:s.restPolicy==="unsupported"?[{type:"workflows"}]:[])
]]);
}
@@ -481,7 +512,7 @@ else if(args[0]==="pr"&&args[1]==="checks") {
else fs.appendFileSync(path,"\\n# changed during checks\\n");
}
if(s.duringChecks?.receiptField) { const receipt=process.env.FIXTURE_REPO+"/.worktrees/pr-123/.local/prep.env"; fs.writeFileSync(receipt,fs.readFileSync(receipt,"utf8").replace(new RegExp("^"+s.duringChecks.receiptField+"=.*$","m"),s.duringChecks.receiptField+"="+main())); }
out([{name:s.requiredCheckName,bucket:s.gates,state:s.gates==="pass"?"SUCCESS":"FAILURE"}]);}
out([{name:s.requiredCheckName,bucket:s.gates,state:s.gates==="pass"?"SUCCESS":s.gates==="pending"?"PENDING":"FAILURE"},...(s.priorCi.otherCheck?[{name:s.priorCi.otherCheck,bucket:"fail",state:"FAILURE"}]:[])]);}
else if(args[0]==="pr"&&args[1]==="view") {
const fields=args[args.indexOf("--json")+1].split(",");
if(fields.includes("headRefName")&&!fields.includes("headRefOid")) fail("missing live cleanup metadata");
@@ -681,7 +712,7 @@ if [ "\${9:-}" = verify ]; then
elif [ -n "\${5:-}" ]; then
merge_complete 123 "$5"
else
merge_run 123 "\${1:-false}" "\${2:-}" "\${3:-}" "\${4:-}" "\${6:-}" "\${7:-false}" "\${8:-}"
merge_run 123 "\${1:-false}" "\${2:-}" "\${3:-}" "\${4:-}" "\${6:-}" "\${7:-false}" "\${8:-}" "\${10:-}" "\${11:-false}"
fi
`,
true,
@@ -726,6 +757,8 @@ fi
cancelAuto = false,
refusalDirectory = "",
verifyOnly = false,
adminEvidence = "",
confirmedAdmin = false,
) => {
const result = spawnSync(
nodeExecutable,
@@ -743,6 +776,8 @@ fi
String(cancelAuto),
refusalDirectory,
verifyOnly ? "verify" : "",
adminEvidence,
String(confirmedAdmin),
],
{
cwd,
@@ -851,6 +886,25 @@ fi
state,
save,
run,
verifyPriorCi: (path: string) => {
const result = spawnSync(
nodeExecutable,
[
...nodeArgs,
join(scripts, "pr-lib/merge-prior-ci.mjs"),
"verify",
path,
"fixture/repo",
"123",
head,
state().operator,
],
{ cwd: worktree, env, encoding: "utf8" },
);
return { ...result, output: result.stdout + result.stderr };
},
adminPriorCi: (path: string) =>
run(false, repo, "squash", "", "", "", "", "", false, "", false, path, true),
complete: (oid: string) => run(false, repo, "squash", "", "", "", oid),
verify: () => run(false, repo, "squash", "", "", "", "", "", false, "", true),
cancel: (oid: string) => run(false, repo, "squash", oid, "", "", "", "", true),
+156
View File
@@ -0,0 +1,156 @@
import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createMergeOutcomeFixtureHarness } from "./pr-merge-outcome.test-support.js";
const { describePosix, fixture } = createMergeOutcomeFixtureHarness();
function candidate() {
const f = fixture(undefined, [["first change\n"], ["resolved conflict\n"]]);
const state = f.state();
const path = join(f.root, "admin.json");
state.priorCi.enabled = true;
state.priorCi.evidencePath = path;
state.repoAuthority.owner = { login: "fixture", type: "Organization" };
state.restPolicy = "rules";
state.requiredCheckName = "openclaw/ci-gate";
state.restContexts = ["openclaw/ci-gate", "Security Review"];
state.gates = "pending";
state.pr.mergeStateStatus = "BEHIND";
f.save(state);
writeFileSync(
join(f.worktree, ".local/gates.env"),
`GATES_MODE=github_pending\nHOSTED_GATES_TARGET_HEAD_SHA=${f.head}\n`,
);
const delta = f.git([
"diff",
"--raw",
"--abbrev=40",
"--no-renames",
"-z",
state.priorCi.head,
f.head,
"--",
]);
// The fixture git helper trims output; this raw form terminates with NUL, so
// no bytes belonging to the delta are removed.
const evidence = {
version: 1,
changeKind: "conflict-resolution",
repository: "fixture/repo",
pr: 123,
head: f.head,
priorHead: state.priorCi.head,
runId: 501,
runAttempt: 2,
deltaSha256: createHash("sha256").update(delta).digest("hex"),
reason: "Explicit operator approval after resolving the source conflict",
contracts: ["owner output"],
checks: [
{ command: "owner test", result: "passed", evidence: "Observed resolved owner output" },
],
};
writeFileSync(path, JSON.stringify(evidence));
return { ...f, path, evidence };
}
describePosix("explicit prior-CI admin landing", () => {
it("lands one pinned REST squash and retains honest historical CI and scoped proof", () => {
const f = candidate();
const result = f.adminPriorCi(f.path);
expect(result.status, result.output).toBe(0);
expect(f.state().mutations).toBe(1);
expect(f.state().restMergePayload).toMatchObject({ sha: f.head, merge_method: "squash" });
expect(f.record()).toMatchObject({
phase: "complete",
route: "admin",
head: f.head,
priorCiAdmin: {
priorHead: f.evidence.priorHead,
runId: 501,
runAttempt: 2,
dispatchTransport: "rest",
},
});
expect(f.state().comments[0]?.body).toContain("No current-head CI success is claimed");
});
it("accepts an exact CI workflow path with its GitHub run-attempt ref suffix", () => {
const f = candidate();
const state = f.state();
state.priorCi.workflowPath = ".github/workflows/ci.yml@refs/heads/topic";
f.save(state);
const result = f.verifyPriorCi(f.path);
expect(result.status, result.output).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ head: f.head, runId: 501, runAttempt: 2 });
});
it.each([
"head",
"delta",
"prior-run",
"wrong-branch",
"review",
"null-review",
"threads",
"failed-ci",
"security",
"missing-security",
"wrong-publisher",
"authority",
"changed-evidence",
])("refuses %s before retaining a merge intent or dispatching", (fault) => {
const f = candidate();
const state = f.state();
if (fault === "head") {
f.evidence.head = f.base;
}
if (fault === "delta") {
f.evidence.deltaSha256 = "0".repeat(64);
}
if (fault === "prior-run") {
state.priorCi.runHead = f.base;
}
if (fault === "wrong-branch") {
state.priorCi.branch = "unrelated";
}
if (fault === "review") {
state.priorCi.reviewDecision = "REVIEW_REQUIRED";
}
if (fault === "null-review") {
state.priorCi.reviewDecision = null;
}
if (fault === "threads") {
state.priorCi.requireThreads = true;
state.priorCi.resolved = false;
}
if (fault === "failed-ci") {
state.gates = "fail";
}
if (fault === "security") {
state.priorCi.otherCheck = "Security Review";
state.restFailedContext = "Security Review";
}
if (fault === "missing-security") {
state.priorCi.missingCheck = "Security Review";
}
if (fault === "wrong-publisher") {
state.restCheckApp = 999;
}
if (fault === "authority") {
state.priorCi.membership = "member";
}
if (fault === "changed-evidence") {
state.priorCi.mutateEvidence = true;
}
f.save(state);
writeFileSync(f.path, JSON.stringify(f.evidence));
const result = fault === "changed-evidence" ? f.adminPriorCi(f.path) : f.verifyPriorCi(f.path);
expect(result.status, result.output).not.toBe(0);
expect(result.output).toContain("Prior-CI admin admission:");
expect(f.state().mutations).toBe(0);
expect(
f.git(["for-each-ref", "--format=%(refname)", "refs/openclaw/pr-merge-outcomes/123"]),
).toBe("");
});
});