diff --git a/.github/workflows/handle-baseline-result.yml b/.github/workflows/handle-baseline-result.yml index 2b846d7300..bcee894d12 100644 --- a/.github/workflows/handle-baseline-result.yml +++ b/.github/workflows/handle-baseline-result.yml @@ -4,6 +4,12 @@ on: workflow_run: workflows: ["Baseline verification"] types: [completed] + workflow_dispatch: + inputs: + baseline_run_id: + description: "Completed Baseline workflow run to process" + required: true + type: string permissions: actions: read @@ -11,7 +17,7 @@ permissions: pull-requests: read concurrency: - group: handle-baseline-${{ github.event.workflow_run.id }} + group: handle-baseline-${{ github.event.workflow_run.id || github.event.inputs.baseline_run_id || github.run_id }} cancel-in-progress: false jobs: @@ -20,9 +26,12 @@ jobs: contents: write pull-requests: write if: >- - github.event.workflow_run.conclusion == 'success' && - (github.event.workflow_run.event == 'pull_request' || - github.event.workflow_run.event == 'workflow_dispatch') + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + (github.event.workflow_run.event == 'pull_request' || + github.event.workflow_run.event == 'workflow_dispatch')) || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.baseline_run_id != '') runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -38,10 +47,10 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 with: script: | - const run = context.payload.workflow_run; const { owner, repo } = context.repo; const { isEligibleAgentPullRequest, + resolveBaselineRun, isPullRequestGateBlocked, resolvePullRequestForRun, waitForPullRequestGate, @@ -49,6 +58,17 @@ jobs: `${process.env.GITHUB_WORKSPACE}/scripts/github/exact-head-pr-gates.cjs`, ); + const run = await resolveBaselineRun({ + github, + owner, + repo, + event: context.payload, + }); + if (!run || run.conclusion !== "success") { + core.info("The requested Baseline run did not complete successfully."); + return; + } + const resolved = await resolvePullRequestForRun({ github, owner, @@ -90,9 +110,12 @@ jobs: core.notice(`Merged PR #${pr.number} immediately after successful final-head Baseline verification.`); qualify: if: >- - github.event.workflow_run.conclusion == 'failure' && - (github.event.workflow_run.event == 'pull_request' || - github.event.workflow_run.event == 'workflow_dispatch') + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'failure' && + (github.event.workflow_run.event == 'pull_request' || + github.event.workflow_run.event == 'workflow_dispatch')) || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.baseline_run_id != '') runs-on: ubuntu-latest outputs: eligible: ${{ steps.gate.outputs.eligible }} @@ -114,9 +137,8 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 with: script: | - const run = context.payload.workflow_run; const { owner, repo } = context.repo; - const { resolvePullRequestForRun } = require( + const { resolveBaselineRun, resolvePullRequestForRun } = require( `${process.env.GITHUB_WORKSPACE}/scripts/github/exact-head-pr-gates.cjs`, ); const deny = (reason) => { @@ -125,6 +147,14 @@ jobs: core.notice(`Automatic repair skipped: ${reason}`); }; + const run = await resolveBaselineRun({ + github, + owner, + repo, + event: context.payload, + }); + if (!run || run.conclusion !== 'failure') return deny('the requested Baseline run did not fail'); + const resolved = await resolvePullRequestForRun({ github, owner, @@ -207,7 +237,7 @@ jobs: - name: Capture failed Baseline logs env: GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ github.event.workflow_run.id }} + RUN_ID: ${{ github.event.workflow_run.id || github.event.inputs.baseline_run_id }} run: | mkdir -p .ross-autofix gh run view "$RUN_ID" --log-failed > .ross-autofix/baseline-failure.log @@ -370,7 +400,7 @@ jobs: if: steps.parse.outputs.apply == 'true' env: HEAD_REF: ${{ needs.qualify.outputs.head_ref }} - FAILED_RUN: ${{ github.event.workflow_run.id }} + FAILED_RUN: ${{ github.event.workflow_run.id || github.event.inputs.baseline_run_id }} run: | set -euo pipefail git config user.name "github-actions[bot]" diff --git a/.github/workflows/sync-upstream-mike-escalated.yml b/.github/workflows/sync-upstream-mike-escalated.yml index 17adc53266..88d95b4e38 100644 --- a/.github/workflows/sync-upstream-mike-escalated.yml +++ b/.github/workflows/sync-upstream-mike-escalated.yml @@ -639,34 +639,25 @@ jobs: pr_url="$(gh pr create --base main --head "$branch" --title "$title" --body-file /tmp/pr-body.md)" fi pr_number="$(gh pr view "$pr_url" --json number --jq .number)" - if [ "$RISK" != "high" ]; then gh workflow run baseline.yml --ref "$branch"; fi echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + echo "branch=$branch" >> "$GITHUB_OUTPUT" - - name: Wait for automatic merge and continue the escalated queue + - name: Hand the Baseline result to the existing gate + id: settle if: steps.finalize_full.outputs.risk != 'high' env: GH_TOKEN: ${{ github.token }} - RECONSIDER_ALL_DEFERRED: ${{ needs.scan.outputs.reconsider_all_deferred }} + BRANCH: ${{ steps.publish.outputs.branch }} PR_NUMBER: ${{ steps.publish.outputs.pr_number }} + run: bash scripts/github/settle-baseline-run.sh "$BRANCH" "$PR_NUMBER" + + - name: Continue the escalated queue + if: steps.settle.outputs.merged == 'true' + env: + GH_TOKEN: ${{ github.token }} + RECONSIDER_ALL_DEFERRED: ${{ needs.scan.outputs.reconsider_all_deferred }} run: | set -euo pipefail - deadline=$((SECONDS + 7200)) - merged_at="" - while [ "$SECONDS" -lt "$deadline" ]; do - pr="$(gh pr view "$PR_NUMBER" --json state,mergedAt)" - merged_at="$(jq -r '.mergedAt // empty' <<<"$pr")" - state="$(jq -r '.state' <<<"$pr")" - if [ -n "$merged_at" ]; then - echo "Escalated synchronization PR #${PR_NUMBER} merged at ${merged_at}." - break - fi - if [ "$state" != "OPEN" ]; then - echo "Escalated synchronization PR #${PR_NUMBER} closed without merging." >&2 - exit 1 - fi - sleep 20 - done - test -n "$merged_at" if [ "$RECONSIDER_ALL_DEFERRED" = "true" ]; then gh workflow run sync-upstream-mike-escalated.yml --ref main -f reconsider_all_deferred=true else diff --git a/.github/workflows/sync-upstream-mike.yml b/.github/workflows/sync-upstream-mike.yml index 14f2fea098..561e398ea7 100644 --- a/.github/workflows/sync-upstream-mike.yml +++ b/.github/workflows/sync-upstream-mike.yml @@ -44,6 +44,7 @@ jobs: script: | const fs = require("fs"); const { execFileSync } = require("child_process"); + const { findExistingUnmergedMikeProposal } = await import("./scripts/lib/mike-sync.mjs"); const state = JSON.parse( fs.readFileSync("docs/upstream-mike-sync-state.json", "utf8"), ); @@ -81,11 +82,28 @@ jobs: const time = new Date(left.merged_at).getTime() - new Date(right.merged_at).getTime(); return time || left.number - right.number; }); - const window = candidates.slice(0, limit); + const proposals = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "all", + base: "main", + per_page: 100, + }); + const boundedCandidates = candidates.slice(0, limit); + const blockedIndex = boundedCandidates.findIndex((candidate) => + findExistingUnmergedMikeProposal(proposals, candidate.number), + ); + const window = blockedIndex >= 0 + ? boundedCandidates.slice(0, blockedIndex) + : boundedCandidates; core.setOutput("base_sha", execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim()); if (window.length === 0) { core.setOutput("has_window", "false"); - core.notice("No unprocessed merged upstream Mike PRs are newer than the sync cursor."); + if (blockedIndex >= 0) { + core.notice("An existing unmerged automated proposal is already the next low-risk queue item; waiting for reconciliation."); + } else { + core.notice("No unprocessed merged upstream Mike PRs are newer than the sync cursor."); + } return; } @@ -357,6 +375,7 @@ jobs: git add docs/upstream-mike-sync-state.json - name: Publish one capability proposal and settle its PR + id: publish env: GH_TOKEN: ${{ github.token }} PR_TITLE: ${{ steps.parse.outputs.title }} @@ -382,27 +401,10 @@ jobs: pr_url="$(gh pr create --base main --head "$branch" --title "$PR_TITLE" --body-file /tmp/pr-body.md)" pr_number="$(gh pr view "$pr_url" --json number --jq .number)" - gh workflow run baseline.yml --ref "$branch" - - deadline=$((SECONDS + 7200)) - merged_at="" - while [ "$SECONDS" -lt "$deadline" ]; do - pr="$(gh pr view "$pr_number" --json state,mergedAt)" - merged_at="$(jq -r '.mergedAt // empty' <<<"$pr")" - state="$(jq -r '.state' <<<"$pr")" - if [ -n "$merged_at" ]; then - echo "Synchronization PR #${pr_number} merged at ${merged_at}." - break - fi - if [ "$state" != "OPEN" ]; then - echo "Synchronization PR #${pr_number} closed without merging." >&2 - exit 1 - fi - sleep 20 - done - test -n "$merged_at" + bash scripts/github/settle-baseline-run.sh "$branch" "$pr_number" - name: Continue Mike capability queues + if: steps.publish.outputs.merged == 'true' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/scripts/github/exact-head-pr-gates.cjs b/scripts/github/exact-head-pr-gates.cjs index a35437c537..eed4486d4a 100644 --- a/scripts/github/exact-head-pr-gates.cjs +++ b/scripts/github/exact-head-pr-gates.cjs @@ -19,6 +19,20 @@ const isEligibleAgentPullRequest = ( (allowMikeSyncBot && isMarkedMikeSyncBot(pr))) && (verifiedHead === null || pr.head.sha === verifiedHead); +const resolveBaselineRun = async ({ github, owner, repo, event }) => { + if (event?.workflow_run) return event.workflow_run; + + const runId = Number(event?.inputs?.baseline_run_id); + if (!Number.isSafeInteger(runId) || runId <= 0) return null; + + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: runId, + }); + return run.name === "Baseline verification" ? run : null; +}; + const resolvePullRequestForRun = async ({ github, owner, repo, run }) => { const linked = run.pull_requests?.[0]; if (run.event === "pull_request" && linked) { @@ -129,6 +143,7 @@ const isPullRequestGateBlocked = ({ gate, expectedHead }) => { module.exports = { isEligibleAgentPullRequest, isPullRequestGateBlocked, + resolveBaselineRun, resolvePullRequestForRun, waitForPullRequestGate, }; diff --git a/scripts/github/settle-baseline-run.sh b/scripts/github/settle-baseline-run.sh new file mode 100644 index 0000000000..b9795253aa --- /dev/null +++ b/scripts/github/settle-baseline-run.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +branch="${1:?branch is required}" +pr_number="${2:?pull request number is required}" +head_sha="$(git rev-parse HEAD)" +output_file="${GITHUB_OUTPUT:-}" + +set_output() { + if [ -n "$output_file" ]; then + printf '%s=%s\n' "$1" "$2" >> "$output_file" + fi +} + +set_output merged false + +echo "Dispatching exact-head Baseline for ${branch} (${head_sha})." +gh workflow run baseline.yml --ref "$branch" + +baseline_run_id="" +for _ in $(seq 1 30); do + baseline_run_id="$(gh run list --workflow baseline.yml --branch "$branch" --limit 50 --json databaseId,headSha | jq -r --arg sha "$head_sha" 'map(select(.headSha == $sha)) | first | .databaseId // empty')" + if [ -n "$baseline_run_id" ]; then break; fi + sleep 2 +done + +if [ -z "$baseline_run_id" ]; then + echo "::notice::Baseline dispatch was accepted but its exact run ID was not observable; leaving PR #${pr_number} pending for reconciliation." + exit 0 +fi + +echo "Baseline run ${baseline_run_id} is attached to exact head ${head_sha}." +baseline_deadline=$((SECONDS + 900)) +baseline_status="" +baseline_conclusion="" +while [ "$SECONDS" -lt "$baseline_deadline" ]; do + run="$(gh run view "$baseline_run_id" --json status,conclusion,headSha)" + baseline_status="$(jq -r '.status' <<<"$run")" + baseline_conclusion="$(jq -r '.conclusion // empty' <<<"$run")" + if [ "$baseline_status" = "completed" ]; then break; fi + sleep 10 +done + +if [ "$baseline_status" != "completed" ]; then + echo "::notice::Baseline run ${baseline_run_id} did not complete within the bounded handoff window; leaving PR #${pr_number} pending." + exit 0 +fi + +echo "Baseline run ${baseline_run_id} completed with conclusion ${baseline_conclusion}." +if ! gh workflow run handle-baseline-result.yml --ref main -f baseline_run_id="$baseline_run_id"; then + echo "::notice::Could not dispatch the existing Baseline-result gate for run ${baseline_run_id}; leaving PR #${pr_number} pending." + exit 0 +fi + +merge_deadline=$((SECONDS + 600)) +while [ "$SECONDS" -lt "$merge_deadline" ]; do + pr="$(gh pr view "$pr_number" --json state,mergedAt)" + merged_at="$(jq -r '.mergedAt // empty' <<<"$pr")" + state="$(jq -r '.state' <<<"$pr")" + if [ -n "$merged_at" ]; then + echo "Synchronization PR #${pr_number} merged at ${merged_at}." + set_output merged true + exit 0 + fi + if [ "$state" != "OPEN" ]; then + echo "::notice::Synchronization PR #${pr_number} closed without merging; reconciliation remains authoritative." + exit 0 + fi + sleep 10 +done + +echo "::notice::Synchronization PR #${pr_number} is still pending after the bounded merge handoff window; reconciliation remains authoritative." diff --git a/tests/baseline/exact-head-pr-gates.test.mjs b/tests/baseline/exact-head-pr-gates.test.mjs index 0257da31fc..2feae7861d 100644 --- a/tests/baseline/exact-head-pr-gates.test.mjs +++ b/tests/baseline/exact-head-pr-gates.test.mjs @@ -6,6 +6,7 @@ import gates from "../../scripts/github/exact-head-pr-gates.cjs"; const { isEligibleAgentPullRequest, isPullRequestGateBlocked, + resolveBaselineRun, resolvePullRequestForRun, waitForPullRequestGate, } = gates; @@ -135,6 +136,76 @@ test("workflow runs resolve only one exact open pull request", async () => { ); }); +test("manual handler inputs resolve the requested Baseline run", async () => { + const baseline = { + id: 123, + event: "workflow_dispatch", + head_branch: "agent/example", + head_sha: "verified-head", + name: "Baseline verification", + conclusion: "success", + }; + const calls = []; + const github = { + rest: { + actions: { + getWorkflowRun: async (params) => { + calls.push(params); + return { data: baseline }; + }, + }, + }, + }; + + assert.deepEqual( + await resolveBaselineRun({ + github, + owner, + repo, + event: { inputs: { baseline_run_id: "123" } }, + }), + baseline, + ); + assert.deepEqual(calls, [{ owner, repo, run_id: 123 }]); + assert.equal( + await resolveBaselineRun({ + github, + owner, + repo, + event: { inputs: { baseline_run_id: "not-a-run" } }, + }), + null, + ); + assert.equal( + await resolveBaselineRun({ + github, + owner, + repo, + event: { + inputs: { baseline_run_id: "123" }, + }, + }), + baseline, + ); + const nonBaseline = { ...baseline, name: "Untrusted workflow" }; + const untrustedGithub = { + rest: { + actions: { + getWorkflowRun: async () => ({ data: nonBaseline }), + }, + }, + }; + assert.equal( + await resolveBaselineRun({ + github: untrustedGithub, + owner, + repo, + event: { inputs: { baseline_run_id: "123" } }, + }), + null, + ); +}); + test("the gate reads every review-thread page and retries unknown mergeability", async () => { let calls = 0; const sleeps = []; diff --git a/tests/baseline/ross-mike-sync-v2.test.mjs b/tests/baseline/ross-mike-sync-v2.test.mjs index ae1a151f35..4f2ae52781 100644 --- a/tests/baseline/ross-mike-sync-v2.test.mjs +++ b/tests/baseline/ross-mike-sync-v2.test.mjs @@ -199,6 +199,8 @@ test("unmerged automated proposals suppress duplicate escalation records", () => test("workflow boundaries expose the deliberate deferred pass and bounded repair", () => { const workflow = read(".github/workflows/sync-upstream-mike-escalated.yml"); + const lowRiskWorkflow = read(".github/workflows/sync-upstream-mike.yml"); + const baselineHandler = read(".github/workflows/handle-baseline-result.yml"); assert.match(workflow, /reconsider_all_deferred:/); assert.match(workflow, /one controlled v2 pass over every legacy deferred Mike PR/); assert.match(workflow, /v2_attempted_at/); @@ -206,4 +208,9 @@ test("workflow boundaries expose the deliberate deferred pass and bounded repair assert.match(workflow, /["']maxItems["']:\s*1/); assert.match(workflow, /High-risk or security-sensitive work/); assert.match(workflow, /draft state-only architecture record/); + assert.match(lowRiskWorkflow, /findExistingUnmergedMikeProposal/); + assert.match(lowRiskWorkflow, /settle-baseline-run\.sh/); + assert.match(workflow, /settle-baseline-run\.sh/); + assert.match(baselineHandler, /baseline_run_id:/); + assert.match(baselineHandler, /resolveBaselineRun/); });