Skip to content

Handle Baseline result #22

Handle Baseline result

Handle Baseline result #22

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"