Handle Baseline result #22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Handle Baseline result | |
| on: | |
| workflow_run: | |
| workflows: ["Baseline verification"] | |
| types: [completed] | |
| permissions: | |
| actions: read | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: handle-baseline-${{ github.event.workflow_run.id }} | |
| cancel-in-progress: false | |
| jobs: | |
| merge: | |
| permissions: | |
| 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') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Verify exact PR head and merge | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| const { owner, repo } = context.repo; | |
| const resolvePullRequest = async () => { | |
| const linked = run.pull_requests?.[0]; | |
| if (run.event === "pull_request" && linked) { | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: linked.number, | |
| }); | |
| return { pr, verifiedHead: linked.head.sha }; | |
| } | |
| if (run.event !== "workflow_dispatch" || !run.head_branch) { | |
| return null; | |
| } | |
| const candidates = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: "open", | |
| base: "main", | |
| head: `${owner}:${run.head_branch}`, | |
| per_page: 100, | |
| }); | |
| const matches = candidates.filter( | |
| (pr) => | |
| pr.head.repo?.full_name === `${owner}/${repo}` && | |
| pr.head.ref === run.head_branch && | |
| pr.head.sha === run.head_sha, | |
| ); | |
| if (matches.length !== 1) { | |
| core.info( | |
| `Dispatched Baseline resolved ${matches.length} exact open PRs; expected one.`, | |
| ); | |
| return null; | |
| } | |
| return { pr: matches[0], verifiedHead: run.head_sha }; | |
| }; | |
| const resolved = await resolvePullRequest(); | |
| if (!resolved) { | |
| core.info("No exact pull request is linked to this Baseline run."); | |
| return; | |
| } | |
| const { pr, verifiedHead } = resolved; | |
| const trustedAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); | |
| const markedSyncBot = | |
| pr.user?.login === "github-actions[bot]" && | |
| pr.head.ref.startsWith("agent/upstream-sync-") && | |
| pr.body?.includes("Automated-Upstream-Mike-Sync: true"); | |
| const eligible = | |
| pr.state === "open" && | |
| !pr.draft && | |
| pr.head.repo?.full_name === `${owner}/${repo}` && | |
| pr.head.ref.startsWith("agent/") && | |
| (trustedAssociations.has(pr.author_association) || markedSyncBot) && | |
| pr.head.sha === verifiedHead; | |
| if (!eligible) { | |
| core.info("PR is not an eligible trusted same-repository agent PR at the verified head."); | |
| return; | |
| } | |
| const query = ` | |
| query($owner: String!, $repo: String!, $number: Int!, $after: String) { | |
| repository(owner: $owner, name: $repo) { | |
| pullRequest(number: $number) { | |
| state | |
| isDraft | |
| headRefOid | |
| reviewDecision | |
| mergeable | |
| reviewThreads(first: 100, after: $after) { | |
| nodes { isResolved } | |
| pageInfo { hasNextPage endCursor } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const readGate = async () => { | |
| let after = null; | |
| let pullRequest = null; | |
| let unresolved = false; | |
| do { | |
| const result = await github.graphql(query, { | |
| owner, | |
| repo, | |
| number: pr.number, | |
| after, | |
| }); | |
| pullRequest = result.repository.pullRequest; | |
| unresolved ||= pullRequest.reviewThreads.nodes.some( | |
| (thread) => !thread.isResolved, | |
| ); | |
| after = pullRequest.reviewThreads.pageInfo.hasNextPage | |
| ? pullRequest.reviewThreads.pageInfo.endCursor | |
| : null; | |
| } while (after); | |
| return { pullRequest, unresolved }; | |
| }; | |
| let gate; | |
| for (let attempt = 1; attempt <= 6; attempt += 1) { | |
| gate = await readGate(); | |
| if (gate.pullRequest.mergeable !== "UNKNOWN") break; | |
| core.info(`Mergeability is still UNKNOWN (attempt ${attempt}/6).`); | |
| if (attempt < 6) await new Promise((resolve) => setTimeout(resolve, 10000)); | |
| } | |
| const node = gate.pullRequest; | |
| const blocked = | |
| node.state !== "OPEN" || | |
| node.isDraft || | |
| node.headRefOid !== verifiedHead || | |
| node.reviewDecision === "CHANGES_REQUESTED" || | |
| node.mergeable !== "MERGEABLE" || | |
| gate.unresolved; | |
| if (blocked) { | |
| core.info("PR has changed or has a review/merge blocker; it will not be merged."); | |
| return; | |
| } | |
| await github.rest.pulls.merge({ | |
| owner, | |
| repo, | |
| pull_number: pr.number, | |
| merge_method: "squash", | |
| sha: verifiedHead, | |
| }); | |
| 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') | |
| runs-on: ubuntu-latest | |
| outputs: | |
| eligible: ${{ steps.gate.outputs.eligible }} | |
| pr_number: ${{ steps.gate.outputs.pr_number }} | |
| head_sha: ${{ steps.gate.outputs.head_sha }} | |
| head_ref: ${{ steps.gate.outputs.head_ref }} | |
| reason: ${{ steps.gate.outputs.reason }} | |
| steps: | |
| - name: Qualify trusted low-risk repair | |
| id: gate | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| const { owner, repo } = context.repo; | |
| const deny = (reason) => { | |
| core.setOutput('eligible', 'false'); | |
| core.setOutput('reason', reason); | |
| core.notice(`Automatic repair skipped: ${reason}`); | |
| }; | |
| const resolvePullRequest = async () => { | |
| const linked = run.pull_requests?.[0]; | |
| if (run.event === 'pull_request' && linked) { | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: linked.number, | |
| }); | |
| return { pr, verifiedHead: linked.head.sha }; | |
| } | |
| if (run.event !== 'workflow_dispatch' || !run.head_branch) { | |
| return null; | |
| } | |
| const candidates = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: 'open', | |
| base: 'main', | |
| head: `${owner}:${run.head_branch}`, | |
| per_page: 100, | |
| }); | |
| const matches = candidates.filter( | |
| (pr) => | |
| pr.head.repo?.full_name === `${owner}/${repo}` && | |
| pr.head.ref === run.head_branch && | |
| pr.head.sha === run.head_sha, | |
| ); | |
| return matches.length === 1 | |
| ? { pr: matches[0], verifiedHead: run.head_sha } | |
| : null; | |
| }; | |
| const resolved = await resolvePullRequest(); | |
| if (!resolved) return deny('no exact linked pull request'); | |
| const { pr, verifiedHead } = resolved; | |
| core.setOutput('pr_number', String(pr.number)); | |
| core.setOutput('head_sha', verifiedHead); | |
| core.setOutput('head_ref', pr.head.ref); | |
| const trusted = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); | |
| if (pr.state !== 'open' || pr.draft) return deny('PR is closed or draft'); | |
| if (pr.head.repo?.full_name !== `${owner}/${repo}`) return deny('fork PR'); | |
| if (!pr.head.ref.startsWith('agent/')) return deny('branch is not an agent branch'); | |
| if (!trusted.has(pr.author_association)) return deny('author is not trusted'); | |
| if (pr.head.sha !== verifiedHead) return deny('failed run is not for the current head'); | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner, | |
| repo, | |
| pull_number: pr.number, | |
| per_page: 100, | |
| }); | |
| const protectedPatterns = [ | |
| /^\.github\//, | |
| /(^|\/)migrations?\//i, | |
| /(^|\/)(fly|docker|compose|terraform|infra|deploy)/i, | |
| /(^|\/)(auth|security|crypto|secrets?|permissions?)(\/|\.|-)/i, | |
| /(^|\/)(legal|privacy|governance|release)(\/|\.|-)/i, | |
| /package-lock\.json$/, | |
| /(^|\/)package\.json$/, | |
| /reports\//, | |
| /\.env/i, | |
| ]; | |
| const protectedFile = files.find(({ filename }) => | |
| protectedPatterns.some((pattern) => pattern.test(filename)), | |
| ); | |
| if (protectedFile) return deny(`protected file changed: ${protectedFile.filename}`); | |
| const commits = await github.paginate(github.rest.pulls.listCommits, { | |
| owner, | |
| repo, | |
| pull_number: pr.number, | |
| per_page: 100, | |
| }); | |
| const repairs = commits.filter(({ commit }) => | |
| commit.message.startsWith('Auto-fix Baseline failure'), | |
| ).length; | |
| if (repairs >= 2) return deny('two automatic repair attempts already used'); | |
| core.setOutput('eligible', 'true'); | |
| core.setOutput('reason', 'trusted low-risk PR'); | |
| repair: | |
| needs: qualify | |
| if: needs.qualify.outputs.eligible == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| actions: read | |
| contents: read | |
| outputs: | |
| repair_result: ${{ steps.codex.outputs.final-message }} | |
| steps: | |
| - name: Require OpenAI API key | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| run: test -n "$OPENAI_API_KEY" | |
| - name: Check out exact failed head without credentials | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.qualify.outputs.head_sha }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Capture failed Baseline logs | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RUN_ID: ${{ github.event.workflow_run.id }} | |
| run: | | |
| mkdir -p .ross-autofix | |
| gh run view "$RUN_ID" --log-failed > .ross-autofix/baseline-failure.log | |
| test -s .ross-autofix/baseline-failure.log | |
| - name: Prepare bounded repair instructions | |
| env: | |
| PR_NUMBER: ${{ needs.qualify.outputs.pr_number }} | |
| HEAD_SHA: ${{ needs.qualify.outputs.head_sha }} | |
| run: | | |
| cat > .ross-autofix/repair-prompt.md <<'PROMPT' | |
| Diagnose the failed ROSS Baseline using .ross-autofix/baseline-failure.log and the checked-out repository. Return the smallest correct unified git patch that resolves the concrete failure. | |
| You are operating in deliberately bounded automatic-repair mode: | |
| - Do not modify .github/, migrations, deployment or infrastructure files, authentication, security, cryptography, secrets, permissions, legal/privacy/governance/release files, reports, package.json, or package-lock.json. | |
| - Do not weaken, skip, delete, or broadly disable tests, audits, lint rules, validation, authorization, privacy controls, or release controls. | |
| - Do not add dependencies, change public APIs, alter database schemas, or make architectural refactors. | |
| - Prefer a narrow implementation fix. A narrow test expectation correction is allowed only when the log proves the implementation is correct and the expectation is stale. | |
| - Do not modify the working tree, commit, or push. | |
| - If a safe bounded repair is unavailable, return status "unsafe" or "no-fix" with an empty patch. | |
| - For status "fix", return a complete unified patch produced against the exact checked-out HEAD, beginning with "diff --git". Do not wrap the patch in Markdown fences. | |
| PROMPT | |
| printf '\nPR: %s\nExact failed head: %s\n' "$PR_NUMBER" "$HEAD_SHA" >> .ross-autofix/repair-prompt.md | |
| - name: Produce read-only structured repair | |
| id: codex | |
| uses: openai/codex-action@dd78cb653811af44014baa08fe954e28d32c1bf9 | |
| with: | |
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | |
| prompt-file: .ross-autofix/repair-prompt.md | |
| permission-profile: ":read-only" | |
| safety-strategy: drop-sudo | |
| allow-bots: true | |
| allow-bot-users: github-actions | |
| output-schema: | | |
| { | |
| "type": "object", | |
| "additionalProperties": false, | |
| "properties": { | |
| "status": { | |
| "type": "string", | |
| "enum": ["fix", "unsafe", "no-fix"] | |
| }, | |
| "reason": { | |
| "type": "string" | |
| }, | |
| "patch": { | |
| "type": "string" | |
| } | |
| }, | |
| "required": ["status", "reason", "patch"] | |
| } | |
| commit: | |
| needs: [qualify, repair] | |
| if: needs.repair.result == 'success' && needs.repair.outputs.repair_result != '' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| actions: write | |
| contents: write | |
| pull-requests: read | |
| steps: | |
| - name: Check out current PR branch in a clean runner | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.qualify.outputs.head_ref }} | |
| fetch-depth: 0 | |
| - name: Confirm exact head is unchanged | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 | |
| env: | |
| EXPECTED_HEAD: ${{ needs.qualify.outputs.head_sha }} | |
| PR_NUMBER: ${{ needs.qualify.outputs.pr_number }} | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: Number(process.env.PR_NUMBER), | |
| }); | |
| if (pr.state !== 'open' || pr.head.sha !== process.env.EXPECTED_HEAD) { | |
| core.setFailed('PR head changed before automatic repair commit.'); | |
| } | |
| - name: Parse structured repair in the clean runner | |
| id: parse | |
| env: | |
| REPAIR_RESULT: ${{ needs.repair.outputs.repair_result }} | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import os | |
| from pathlib import Path | |
| result = json.loads(os.environ["REPAIR_RESULT"]) | |
| status = result.get("status") | |
| reason = str(result.get("reason", "")) | |
| patch = str(result.get("patch", "")) | |
| apply = status == "fix" | |
| if apply: | |
| if not patch.startswith("diff --git "): | |
| raise SystemExit("Structured repair did not contain a unified git patch") | |
| if "\x00" in patch: | |
| raise SystemExit("Structured repair contains a NUL byte") | |
| encoded = patch.encode("utf-8") | |
| if len(encoded) > 200_000: | |
| raise SystemExit("Automatic repair patch exceeds 200 KB") | |
| Path("/tmp/ross-autofix.patch").write_bytes(encoded) | |
| elif patch.strip(): | |
| raise SystemExit("Non-fix structured result must have an empty patch") | |
| with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: | |
| output.write(f"apply={'true' if apply else 'false'}\n") | |
| with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: | |
| summary.write(f"## Automatic repair decision\n\n- Status: `{status}`\n- Reason: {reason}\n") | |
| PY | |
| - name: Apply and validate bounded patch | |
| if: steps.parse.outputs.apply == 'true' | |
| run: | | |
| set -euo pipefail | |
| git apply --check --whitespace=error-all /tmp/ross-autofix.patch | |
| git apply --index --whitespace=error-all /tmp/ross-autofix.patch | |
| mapfile -t changed < <(git diff --cached --name-only) | |
| test "${#changed[@]}" -gt 0 | |
| test "${#changed[@]}" -le 8 | |
| test -z "$(git diff --cached --diff-filter=D --name-only)" | |
| test -z "$(git diff --cached --diff-filter=RCTU --name-only)" | |
| for path in "${changed[@]}"; do | |
| case "$path" in | |
| backend/src/*|backend/tests/*|frontend/src/*|website/src/*|website/tests/*|tests/*|scripts/*) ;; | |
| *) echo "Unsafe automatic-repair path: $path" >&2; exit 1 ;; | |
| esac | |
| if [[ "$path" =~ (^|/)(auth|security|crypto|secret|permission|legal|privacy|governance|release|deploy|migration) ]]; then | |
| echo "Protected automatic-repair path: $path" >&2 | |
| exit 1 | |
| fi | |
| done | |
| if git diff --cached --numstat | awk '$1 == "-" || $2 == "-" { found=1 } END { exit !found }'; then | |
| echo "Binary automatic repairs are not permitted." >&2 | |
| exit 1 | |
| fi | |
| total_lines="$(git diff --cached --numstat | awk '{ total += $1 + $2 } END { print total + 0 }')" | |
| test "$total_lines" -le 800 | |
| if git diff --cached --summary | grep -Eq 'mode change|create mode 100755|create mode 120000|create mode 160000|delete mode'; then | |
| echo "File-mode, executable, symlink, submodule, or deletion changes are not permitted." >&2 | |
| exit 1 | |
| fi | |
| - name: Commit and push bounded repair | |
| if: steps.parse.outputs.apply == 'true' | |
| env: | |
| HEAD_REF: ${{ needs.qualify.outputs.head_ref }} | |
| FAILED_RUN: ${{ github.event.workflow_run.id }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git commit -m "Auto-fix Baseline failure from run ${FAILED_RUN}" | |
| git push origin "HEAD:${HEAD_REF}" | |
| - name: Dispatch Baseline for repaired exact head | |
| if: steps.parse.outputs.apply == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| HEAD_REF: ${{ needs.qualify.outputs.head_ref }} | |
| run: gh workflow run baseline.yml --ref "$HEAD_REF" |