Skip to content

PR #2071 (classify) #2660

PR #2071 (classify)

PR #2071 (classify) #2660

Workflow file for this run

name: Pull request automation

Check warning on line 1 in .github/workflows/pr-automation.yml

View workflow run for this annotation

GitHub Actions / Pull request automation

Workflow execution policy warning (evaluate mode)

On November 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
# Prefer an exact PR number when the event carries one; otherwise identify the CI head that triggered the workflow.
# The trailing mode mirrors the route the triggering event takes, so classifier and reviewer runs are told apart at a glance.
run-name: >-
${{
format('{0} ({1})',
github.event.pull_request.number && format('PR #{0}', github.event.pull_request.number)
|| inputs.pr-number && format('PR #{0}', inputs.pr-number)
|| github.event.client_payload.pr_number && format('PR #{0}', github.event.client_payload.pr_number)
|| github.event.workflow_run.pull_requests[0].number && format('PR #{0}', github.event.workflow_run.pull_requests[0].number)
|| github.event.workflow_run.head_branch && format('{0} @ {1}', github.event.workflow_run.head_branch, github.event.workflow_run.head_sha)
|| format('run {0}', github.run_id),
(github.event_name == 'workflow_run' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'workflow_dispatch' && inputs.review)) && 'review' || 'classify')
}}
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, closed, edited, labeled, unlabeled]
workflow_run:
workflows: [CI]
types: [requested, in_progress, completed]
repository_dispatch:
types: [pr-automation-classified]
workflow_dispatch:
inputs:
pr-number:
description: Pull request number to classify or review
required: true
type: number
review:
description: Run the review route instead of classification
required: true
default: false
type: boolean
force:
description: Bypass automation gates for this manual classifier or reviewer run
required: true
default: false
type: boolean
concurrency:
# Keep event routes separate while retaining every bounded pending wake-up.
# Admission revalidates current state, so stale queued work safely becomes a no-op.
group: >-
pr-automation-${{ github.event_name }}-${{ github.event.workflow_run.head_sha ||
github.event.pull_request.number || inputs.pr-number ||
github.event.client_payload.pr_number ||
github.run_id }}-${{ github.event.label.name &&
format('label-{0}', github.event.label.name) || 'pr' }}
cancel-in-progress: false
queue: max
permissions: {}
jobs:
resolve-pr:
name: Resolve pull request
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
ok: ${{ steps.resolve.outputs.ok }}
route: ${{ steps.resolve.outputs.route }}
pr-number: ${{ steps.resolve.outputs.pr-number }}
head-sha: ${{ steps.resolve.outputs.head-sha }}
base-sha: ${{ steps.resolve.outputs.base-sha }}
labels: ${{ steps.resolve.outputs.labels }}
author: ${{ steps.resolve.outputs.author }}
force: ${{ steps.resolve.outputs.force }}
observed-ci-run: ${{ steps.resolve.outputs.observed-ci-run }}
review-requested: ${{ steps.resolve.outputs.review-requested }}
classification-requested: ${{ steps.resolve.outputs.classification-requested }}
review-route: ${{ steps.resolve.outputs.review-route }}
classifier-lane: ${{ steps.resolve.outputs.classifier-lane }}
reviewer-pipeline-lane: ${{ steps.resolve.outputs.reviewer-pipeline-lane }}
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- id: resolve
uses: actions/github-script@v8
env:
PULL_REQUEST_URL_BASE: ${{ github.server_url }}/${{ github.repository }}/pull
with:
script: |
const { resolvePr } = require("./.github/pr-automation/resolve-pr");
const result = await resolvePr({
github,
context,
inputs: {
prNumber: context.payload.inputs?.["pr-number"],
force: context.payload.inputs?.force,
review: context.payload.inputs?.review,
},
});
core.setOutput("ok", result.ok === true);
core.setOutput("route", result.route ?? "");
core.setOutput("pr-number", result.prNumber ?? "");
core.setOutput("head-sha", result.headSha ?? "");
core.setOutput("base-sha", result.baseSha ?? "");
core.setOutput("labels", JSON.stringify(result.labels ?? []));
core.setOutput("author", JSON.stringify(result.author ?? null));
core.setOutput("force", result.force === true);
core.setOutput("observed-ci-run", result.observedCiRun ? JSON.stringify(result.observedCiRun) : "");
core.setOutput("review-requested", result.reviewRequested === true);
core.setOutput("classification-requested", result.classificationRequested === true);
core.setOutput("review-route", result.reviewRoute === true);
core.setOutput("classifier-lane", result.prNumber ? (Number(result.prNumber) % 4) + 1 : "");
core.setOutput("reviewer-pipeline-lane", result.prNumber ? (Number(result.prNumber) % 7) + 1 : "");
core.info(JSON.stringify({ event: "pr-automation.resolve-pr", decision: result }));
if (result.prNumber) {
const prUrl = `${process.env.PULL_REQUEST_URL_BASE}/${result.prNumber}`;
await core.summary
.addRaw(`Resolved pull request [#${result.prNumber}](${prUrl}).`, true)
.write();
}
if (!result.ok) core.warning(`PR automation did not start: ${result.reason}`);
classification-gate:
name: Gate pull request classification
needs: resolve-pr
if: >-
needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.classification-requested == 'true'
runs-on: ubuntu-latest
permissions:
checks: read
contents: read
outputs:
required: ${{ steps.gate.outputs.required }}
available: ${{ steps.gate.outputs.available }}
completed: ${{ steps.gate.outputs.completed }}
reason: ${{ steps.gate.outputs.reason }}
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- id: gate
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
FORCE: ${{ needs.resolve-pr.outputs.force }}
with:
script: |
const { resolveClassificationGate } = require("./.github/pr-automation/classification-gate");
const decision = await resolveClassificationGate({
github,
owner: context.repo.owner,
repo: context.repo.repo,
expectedSha: process.env.HEAD_SHA,
force: process.env.FORCE === "true",
});
core.setOutput("required", decision.required);
core.setOutput("available", decision.available);
core.setOutput("completed", decision.completed === true);
core.setOutput("reason", decision.reason);
if (decision.error) core.warning(`Classification gate unavailable: ${decision.error}`);
core.info(JSON.stringify({
event: "pr-automation.classification-gate",
decision,
}));
deterministic-analysis:
name: Calculate deterministic labels
needs: resolve-pr
if: >-
needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.classification-requested == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
# github-script owns the result output and overwrites it with the script return value.
analysis: ${{ steps.analyze.outputs.analysis }}
ok: ${{ steps.analyze.outputs.ok }}
size-label: ${{ steps.analyze.outputs.size-label }}
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- id: analyze
uses: actions/github-script@v8
env:
PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }}
with:
script: |
const { deterministicAnalysis } = require("./.github/pr-automation/deterministic-analysis");
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(process.env.PULL_REQUEST_NUMBER),
});
const result = await deterministicAnalysis({
github,
owner: context.repo.owner,
repo: context.repo.repo,
prNumber: pullRequest.number,
authorAssociation: pullRequest.author_association,
labelerPath: "./.github/labeler.yml",
});
core.setOutput("analysis", JSON.stringify(result));
core.setOutput("ok", result.ok === true);
core.setOutput("size-label", result.sizeLabel || "");
core.info(JSON.stringify({ event: "pr-automation.deterministic-analysis", decision: result }));
classification-admission:
name: Claim classification automation
needs: [resolve-pr, classification-gate]
if: >-
always() && !cancelled() &&
needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.classification-requested == 'true' &&
needs.classification-gate.result == 'success' &&
(needs.resolve-pr.outputs.force == 'true' ||
(needs.classification-gate.outputs.available == 'true' &&
needs.classification-gate.outputs.required == 'true'))
runs-on: ubuntu-latest
concurrency:
group: pr-automation-mutation-${{ needs.resolve-pr.outputs.pr-number }}
cancel-in-progress: false
queue: max
permissions:
actions: read
checks: write
contents: read
pull-requests: read
outputs:
owner: ${{ steps.claim.outputs.owner }}
lease: ${{ steps.claim.outputs.lease }}
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- id: claim
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }}
FORCE: ${{ needs.resolve-pr.outputs.force }}
ALLOW_SUCCESS: ${{ needs.resolve-pr.outputs.review-requested }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
with:
script: |
if (process.env.FORCE === "true") {
core.setOutput("owner", true);
core.setOutput("lease", "");
return;
}
const { claimAutomaticLease } = require("./.github/pr-automation/automation-lease");
const decision = await claimAutomaticLease({
github, owner: context.repo.owner, repo: context.repo.repo,
kind: "classification", headSha: process.env.HEAD_SHA,
prNumber: Number(process.env.PULL_REQUEST_NUMBER),
runId: Number(process.env.RUN_ID), attempt: Number(process.env.RUN_ATTEMPT),
allowSuccess: process.env.ALLOW_SUCCESS === "true",
});
core.setOutput("owner", decision.owner);
core.setOutput("lease", decision.lease ? JSON.stringify(decision.lease) : "");
core.info(JSON.stringify({ event: "pr-automation.classification-admission", decision }));
fork-rate-limit:
name: Check fork automation quota
needs: resolve-pr
if: needs.resolve-pr.outputs.ok == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
quota: ${{ steps.limit.outputs.quota }}
allowed: ${{ steps.limit.outputs.allowed }}
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- id: limit
uses: actions/github-script@v8
env:
PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }}
AUTHOR: ${{ needs.resolve-pr.outputs.author }}
with:
script: |
const { forkRateLimit } = require("./.github/pr-automation/fork-rate-limit");
let author = null;
let result;
try {
author = JSON.parse(process.env.AUTHOR);
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(process.env.PULL_REQUEST_NUMBER),
});
result = await forkRateLimit({
github,
owner: context.repo.owner,
repo: context.repo.repo,
pr: pullRequest,
author,
});
} catch {
result = { status: "unavailable", scope: "unknown", reason: "GitHub API unavailable" };
}
core.setOutput("quota", JSON.stringify(result));
core.setOutput("allowed", result.status === "allowed");
core.info(JSON.stringify({ event: "pr-automation.fork-rate-limit", decision: result }));
semver:
name: Check public API compatibility
needs: [resolve-pr, classification-gate]
if: >-
always() && !cancelled() &&
needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.classification-requested == 'true' &&
(needs.resolve-pr.outputs.force == 'true' ||
(needs.classification-gate.outputs.available == 'true' &&
needs.classification-gate.outputs.required == 'true'))
runs-on: ubuntu-latest
permissions: {}
outputs:
compatibility: ${{ steps.semver.outputs.compatibility }}
env:
CARGO_SEMVER_CHECKS_VERSION: "0.51.0"
CARGO_SEMVER_CHECKS_SHA256: "cacadafaa5cd27ae6d05a74a103266917ebab4c5e43c898360e177d4fbf06a65"
CARGO_SEMVER_CHECKS_FEATURES: "core,pdu,cliprdr,connector,acceptor,session,graphics,input,server,svc,dvc,rdpdr,rdpsnd,displaycontrol,echo,mstsgu,client,rustls,client-all,qoi,qoiz"
CARGO_SEMVER_CHECKS_DIR: /tmp/ironrdp-cargo-semver-checks-${{ github.run_id }}-${{ github.run_attempt }}
SEMVER_WORKSPACE: /tmp/ironrdp-semver-workspace-${{ github.run_id }}-${{ github.run_attempt }}
SEMVER_STATE_DIR: /tmp/ironrdp-semver-state-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- id: fetch
shell: bash
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
BASE_SHA: ${{ needs.resolve-pr.outputs.base-sha }}
run: |
set -euo pipefail
workspace="$SEMVER_WORKSPACE"
install -d -m 755 "$workspace"
cd "$workspace"
export GIT_CONFIG_NOSYSTEM=1
export GIT_CONFIG_GLOBAL=/dev/null
git init .
git config --local core.hooksPath /dev/null
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
git fetch --no-tags origin "+$HEAD_SHA:refs/remotes/origin/pull-request-head"
git fetch --no-tags origin "+$BASE_SHA:refs/remotes/origin/pull-request-base"
git checkout --detach origin/pull-request-head
- id: install
shell: bash
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get -y install libasound2-dev
install -d -m 700 "$CARGO_SEMVER_CHECKS_DIR"
curl --fail --location \
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz" \
--output /tmp/cargo-semver-checks.tar.gz
echo "$CARGO_SEMVER_CHECKS_SHA256 /tmp/cargo-semver-checks.tar.gz" | sha256sum --check --strict
tar -xzf /tmp/cargo-semver-checks.tar.gz -C "$CARGO_SEMVER_CHECKS_DIR" cargo-semver-checks
sudo chown -R nobody:nogroup "$CARGO_SEMVER_CHECKS_DIR"
sudo chmod 700 "$CARGO_SEMVER_CHECKS_DIR/cargo-semver-checks"
- id: semver
shell: bash
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
BASE_SHA: ${{ needs.resolve-pr.outputs.base-sha }}
run: |
set -euo pipefail
semver_home="$SEMVER_STATE_DIR/home"
semver_cargo="$SEMVER_STATE_DIR/cargo"
semver_target="$SEMVER_STATE_DIR/target"
workspace="$SEMVER_WORKSPACE"
rustc_bin="$(rustup which rustc)"
rust_toolchain_dir="$(dirname "$(dirname "$rustc_bin")")"
rust_bin_dir="$rust_toolchain_dir/bin"
install -d -m 700 "$semver_home" "$semver_cargo" "$semver_target"
chmod -R a+rX "$workspace"
sudo chown -R nobody:nogroup "$workspace" "$semver_home" "$semver_cargo" "$semver_target"
sudo chmod o+x "$HOME" "$HOME/.rustup" "$HOME/.rustup/toolchains"
sudo chmod -R a+rX "$rust_toolchain_dir"
cd "$workspace"
# Build scripts and proc macros run as nobody, which cannot read the runner command files.
set +e
sudo -u nobody env -i \
HOME="$semver_home" \
CARGO_HOME="$semver_cargo" \
PATH="$rust_bin_dir:$PATH" \
RUSTC_BOOTSTRAP=1 \
RUSTDOCFLAGS="-Z unstable-options --document-private-items --document-hidden-items --output-format=json --cap-lints=allow" \
HEAD_SHA="$HEAD_SHA" \
BASE_SHA="$BASE_SHA" \
CARGO_SEMVER_CHECKS_DIR="$CARGO_SEMVER_CHECKS_DIR" \
CARGO_SEMVER_CHECKS_FEATURES="$CARGO_SEMVER_CHECKS_FEATURES" \
SEMVER_ARTIFACT_ROOT="$semver_target" \
bash -euo pipefail -c '
# cargo-semver-checks normally resolves a new dependency graph in a
# placeholder crate. Build rustdoc ourselves so both revisions use
# their committed lockfiles, just like the ordinary CI build.
build_rustdoc() {
# Drop anything the previous build left in the source tree.
git clean -xdfq
git checkout --detach "$2"
CARGO_TARGET_DIR="$SEMVER_ARTIFACT_ROOT/$1" cargo rustdoc --locked \
--package ironrdp --lib --no-default-features --features "$CARGO_SEMVER_CHECKS_FEATURES"
}
build_rustdoc baseline "$BASE_SHA"
build_rustdoc current "$HEAD_SHA"
"$CARGO_SEMVER_CHECKS_DIR/cargo-semver-checks" \
--current-rustdoc "$SEMVER_ARTIFACT_ROOT/current/doc/ironrdp.json" \
--baseline-rustdoc "$SEMVER_ARTIFACT_ROOT/baseline/doc/ironrdp.json"
'
status=$?
set -e
case "$status" in
0) status=not-suspected ;;
100) status=suspected ;;
*) exit "$status" ;;
esac
echo "compatibility={\"head_sha\":\"$HEAD_SHA\",\"status\":\"$status\"}" >> "$GITHUB_OUTPUT"
echo "::notice title=PR automation semver decision::head_sha=$HEAD_SHA status=$status"
classifier:
name: Classify pull request
needs: [resolve-pr, classification-gate, classification-admission, deterministic-analysis, fork-rate-limit]
if: >-
always() && !cancelled() &&
needs.resolve-pr.outputs.ok == 'true' &&
needs.deterministic-analysis.result == 'success' &&
needs.deterministic-analysis.outputs.ok == 'true' &&
(needs.resolve-pr.outputs.force == 'true' ||
needs.classification-admission.outputs.owner == 'true') &&
(needs.resolve-pr.outputs.force == 'true' ||
(needs.classification-gate.outputs.available == 'true' &&
needs.classification-gate.outputs.required == 'true' &&
needs.fork-rate-limit.result == 'success' &&
needs.fork-rate-limit.outputs.allowed == 'true'))
runs-on: ubuntu-latest
timeout-minutes: 40
concurrency:
group: llm-classifier-${{ needs.resolve-pr.outputs.classifier-lane }}
cancel-in-progress: false
queue: max
permissions:
pull-requests: read
environment: llm-providers
outputs:
output: ${{ steps.agent.outputs.structured-output }}
failure-reason: ${{ steps.evidence.outputs.failure-reason || steps.agent.outputs.failure-reason }}
overlap-candidates: ${{ steps.overlap-candidates.outputs.manifest }}
steps:
- id: evidence
name: Fetch base and PR head without credentials
shell: bash
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
BASE_SHA: ${{ needs.resolve-pr.outputs.base-sha }}
run: |
set -euo pipefail
export GIT_CONFIG_NOSYSTEM=1
export GIT_CONFIG_GLOBAL=/dev/null
git init .
git config --local core.hooksPath /dev/null
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
git fetch --no-tags origin +master:refs/remotes/origin/master
git checkout --detach origin/master
if ! bash ./.github/pr-automation/fetch-pr-evidence.sh \
"$HEAD_SHA" "$BASE_SHA"; then
reason="pull request evidence unavailable"
if test -f pr-evidence/failure-reason.txt; then
reason="$(cat pr-evidence/failure-reason.txt)"
fi
echo "failure-reason=$reason" >> "$GITHUB_OUTPUT"
exit 1
fi
- id: overlap-candidates
name: Fetch bounded overlap candidates
uses: actions/github-script@v8
env:
PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }}
with:
script: |
const fs = require("node:fs");
const { data } = await github.rest.pulls.list({
owner: context.repo.owner, repo: context.repo.repo,
state: "open", sort: "updated", direction: "desc", per_page: 100,
});
const current = Number(process.env.PULL_REQUEST_NUMBER);
const candidates = data.filter((pull) => pull.number !== current).slice(0, 30)
.map((pull) => ({
number: pull.number,
url: pull.html_url,
title: String(pull.title || "").slice(0, 300),
body: String(pull.body || "").slice(0, 1000),
head_sha: pull.head?.sha || "",
}));
fs.writeFileSync(
"pr-evidence/overlap-candidates.json",
JSON.stringify({ candidates }, null, 2),
);
core.setOutput("manifest", JSON.stringify(
candidates.map(({ number, url }) => ({ number, url })),
));
- name: Write classifier context
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
with:
script: |
require("node:fs").writeFileSync(
"pr-automation-context.json",
JSON.stringify({ head_sha: process.env.HEAD_SHA }, null, 2),
);
- id: agent
continue-on-error: true
uses: ./.github/actions/openai-agent
with:
api-key: ${{ secrets.HELMCODE_GLM_API_KEY }}
base-url: https://api.helmcode.com/v1
config-file: .github/pr-automation/agents/classifier.json
normalizer: .github/pr-automation/output-normalizer.js#normalizeClassifier
resolve-classification-state:
name: Resolve classification state
needs: [resolve-pr, classification-gate, classification-admission, deterministic-analysis, fork-rate-limit, semver, classifier]
if: always() && !cancelled() && needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.classification-requested == 'true' &&
(needs.resolve-pr.outputs.force == 'true' ||
needs.classification-admission.outputs.owner == 'true' ||
needs.classification-gate.outputs.available != 'true') &&
(needs.classification-gate.outputs.required == 'true' ||
needs.classification-gate.outputs.available != 'true')
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
state: ${{ steps.resolve.outputs.state }}
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- id: resolve
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
LABELS: ${{ needs.resolve-pr.outputs.labels }}
PR_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }}
DETERMINISTIC: ${{ needs.deterministic-analysis.outputs.analysis }}
CLASSIFIER: ${{ needs.classifier.outputs.output }}
CLASSIFIER_REASON: ${{ needs.classifier.outputs.failure-reason }}
OVERLAP_CANDIDATES: ${{ needs.classifier.outputs.overlap-candidates }}
CLASSIFICATION_GATE_AVAILABLE: ${{ needs.classification-gate.outputs.available }}
CLASSIFICATION_GATE_COMPLETED: ${{ needs.classification-gate.outputs.completed }}
CLASSIFICATION_GATE_REASON: ${{ needs.classification-gate.outputs.reason }}
FORK_RATE_LIMIT: ${{ needs.fork-rate-limit.outputs.quota }}
SEMVER: ${{ needs.semver.outputs.compatibility }}
FORCE: ${{ needs.resolve-pr.outputs.force }}
LEASE: ${{ needs.classification-admission.outputs.lease }}
with:
script: |
const { resolveClassificationState } = require("./.github/pr-automation/resolve-state");
const parse = (value, fallback) => { try { return JSON.parse(value || ""); } catch { return fallback; } };
const state = resolveClassificationState({
expectedSha: process.env.HEAD_SHA,
labels: parse(process.env.LABELS, []),
prNumber: Number(process.env.PR_NUMBER),
deterministic: parse(process.env.DETERMINISTIC, {}),
classifier: process.env.CLASSIFIER || "",
classifierReason: process.env.CLASSIFIER_REASON || "",
overlapCandidates: parse(process.env.OVERLAP_CANDIDATES, []),
classificationGate: {
available: process.env.CLASSIFICATION_GATE_AVAILABLE === "true",
completed: process.env.CLASSIFICATION_GATE_COMPLETED === "true",
reason: process.env.CLASSIFICATION_GATE_REASON || "",
},
rateLimit: parse(process.env.FORK_RATE_LIMIT, {}),
semver: parse(process.env.SEMVER, {}),
force: process.env.FORCE === "true",
});
if (process.env.FORCE !== "true") state.lease = parse(process.env.LEASE, null);
core.setOutput("state", JSON.stringify(state));
core.info(JSON.stringify({
event: "pr-automation.classification-state",
decision: {
ok: state.ok, mode: state.mode, failed: state.failed === true,
expectedSha: state.expectedSha,
},
}));
review-gate:
name: Gate automated review
needs: resolve-pr
if: >-
needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.review-route == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
checks: read
contents: read
pull-requests: read
issues: read
outputs:
gate: ${{ steps.gate.outputs.gate }}
eligible: ${{ steps.gate.outputs.eligible }}
specialist-reviewers: ${{ steps.gate.outputs.specialist-reviewers }}
required-reviewers: ${{ steps.gate.outputs.required-reviewers }}
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- id: gate
uses: actions/github-script@v8
env:
PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }}
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
ROUTE: ${{ needs.resolve-pr.outputs.route }}
LABELS: ${{ needs.resolve-pr.outputs.labels }}
AUTHOR: ${{ needs.resolve-pr.outputs.author }}
FORCE: ${{ needs.resolve-pr.outputs.force }}
OBSERVED_CI_RUN: ${{ needs.resolve-pr.outputs.observed-ci-run }}
with:
script: |
const { SCHEMA_VERSION, parseCheckState } = require("./.github/pr-automation/validate-classifier");
const { contributorEligibility, reviewPolicyEligible, reviewCount } = require("./.github/pr-automation/resolve-state");
const { readCheckRuns } = require("./.github/pr-automation/check-runs");
const { generation, readLatestExactHeadCiRun } = require("./.github/pr-automation/ci-state");
const { resolveReviewerRoute } = require("./.github/pr-automation/routing");
const prNumber = Number(process.env.PULL_REQUEST_NUMBER);
const headSha = process.env.HEAD_SHA;
const force = process.env.FORCE === "true";
const manual = process.env.ROUTE === "dispatch";
let resolvedLabels = [];
let author = null;
let observedRun = null;
try { resolvedLabels = JSON.parse(process.env.LABELS || "[]"); } catch {}
try { author = JSON.parse(process.env.AUTHOR || "null"); } catch {}
try { observedRun = JSON.parse(process.env.OBSERVED_CI_RUN || "null"); } catch {}
const publish = (gate, eligible = gate.ok) => {
const requiredReviewers = (gate.specialistReviewers || []).filter((reviewer) =>
(reviewer === "protocol" && gate.protocolRelated === true) ||
(reviewer === "skeptical" && ["medium", "high"].includes(gate.risk)));
core.setOutput("gate", JSON.stringify(gate));
core.setOutput("eligible", eligible);
core.setOutput("specialist-reviewers", JSON.stringify(gate.specialistReviewers || []));
core.setOutput("required-reviewers", JSON.stringify(requiredReviewers));
core.info(JSON.stringify({
event: "pr-automation.review-gate",
decision: gate,
}));
};
try {
const classificationRuns = await readCheckRuns({
github, owner: context.repo.owner, repo: context.repo.repo, ref: headSha,
checkName: "AI classification",
});
const completed = (runs, externalId) => runs
.filter((run) => run.external_id === externalId)
.sort((left, right) => right.id - left.id)[0];
const classification = completed(classificationRuns, `${SCHEMA_VERSION}:${headSha}`);
const classificationOwned = classification?.conclusion === "success" &&
classification.app?.slug === "github-actions";
// A check without the current machine-readable state predates this contract: fail closed
// and wait for a later classification event to rewrite it.
const protocolState = classificationOwned ? parseCheckState(classification.output?.summary) : null;
const classificationValid = classificationOwned && protocolState !== null;
const classificationCheck = classificationValid &&
classification.output?.title === "Classification complete" &&
protocolState.automaticReviewEligible === true;
const classificationId = classificationValid ? classification.id : null;
const legitimacyStopped = classificationOwned &&
classification.output?.title === "Automation stopped";
const protocolRelated = protocolState?.protocolRelated === true;
if (!classificationValid && (force || manual)) {
publish({
ok: false, force, head_sha: headSha, classificationValid: false,
classificationCheck: false, legitimacyStopped, ciGreen: false,
nextReviewEligible: false, reviewAtHead: false, policyEligible: false, labels: resolvedLabels,
protocolRelated: false, risk: "unknown", specialistReviewers: [],
contributor: { status: force ? "forced" : "unavailable" },
reason: "valid classification unavailable",
}, false);
core.setFailed("manual or forced review requires a valid classification for the current head");
return;
}
if (force) {
const route = resolveReviewerRoute({
suggestedReviewers: protocolState.specialistReviewers,
protocolRelated,
risk: protocolState.risk,
});
publish({
ok: route.ok, force: true, head_sha: headSha, classificationValid, classificationCheck,
classificationId,
legitimacyStopped, ciGreen: false, nextReviewEligible: true,
reviewAtHead: false, policyEligible: true, labels: resolvedLabels, protocolRelated,
risk: protocolState.risk,
specialistReviewers: route.ok ? route.reviewers : [],
contributor: { status: "forced" },
});
return;
}
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber,
});
const labels = issue.labels.map((label) => typeof label === "string" ? label : label.name);
const reviewRuns = await readCheckRuns({
github, owner: context.repo.owner, repo: context.repo.repo, ref: headSha,
checkName: "AI automated review",
});
const reviewAtHead = reviewRuns.some((run) =>
run.external_id === headSha && run.conclusion === "success" &&
run.app?.slug === "github-actions");
const latestCiRun = await readLatestExactHeadCiRun({
github, owner: context.repo.owner, repo: context.repo.repo, expectedSha: headSha,
observedRun,
});
const ciGeneration = generation(latestCiRun);
const ciSource = latestCiRun === null ? null : latestCiRun === observedRun ? "event" : "listing";
const ciGreen = latestCiRun?.conclusion === "success" && ciGeneration !== null;
const nextReviewEligible = reviewCount(labels) !== undefined && !reviewAtHead;
const policyEligible = reviewPolicyEligible({ labels, legitimacyStopped });
const contributor = await contributorEligibility({
github, owner: context.repo.owner, repo: context.repo.repo, author,
currentPrNumber: prNumber,
});
publish({
ok: classificationCheck && ciGreen && nextReviewEligible && policyEligible &&
contributor.status === "eligible",
force: false, head_sha: headSha, classificationValid, classificationCheck, legitimacyStopped,
classificationId,
ciGreen, ciRunId: ciGeneration?.id ?? null,
ciRunAttempt: ciGeneration?.attempt ?? null, ciSource,
nextReviewEligible, reviewAtHead, policyEligible, labels, contributor,
protocolRelated, risk: protocolState?.risk || "unknown",
specialistReviewers: protocolState?.specialistReviewers || [],
});
} catch (error) {
core.warning(`Review gate unavailable: ${error.message}`);
publish({
ok: false, force, head_sha: headSha, classificationValid: false, classificationCheck: false,
classificationId: null,
legitimacyStopped: false, ciGreen: false, nextReviewEligible: force,
reviewAtHead: false, policyEligible: false, labels: force ? resolvedLabels : [], protocolRelated: false,
risk: "unknown", specialistReviewers: [],
contributor: force ? { status: "forced" } : { status: "unavailable" },
reason: error.message,
}, false);
}
review-admission:
name: Claim automated review
needs: [resolve-pr, review-gate]
if: >-
always() && !cancelled() &&
needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.review-route == 'true' &&
needs.review-gate.outputs.eligible == 'true'
runs-on: ubuntu-latest
concurrency:
group: pr-automation-mutation-${{ needs.resolve-pr.outputs.pr-number }}
cancel-in-progress: false
queue: max
permissions:
actions: read
checks: write
contents: read
issues: read
pull-requests: read
outputs:
owner: ${{ steps.claim.outputs.owner }}
lease: ${{ steps.claim.outputs.lease }}
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- id: claim
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }}
GATE: ${{ needs.review-gate.outputs.gate }}
FORCE: ${{ needs.resolve-pr.outputs.force }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
with:
script: |
if (process.env.FORCE === "true") {
core.setOutput("owner", true);
core.setOutput("lease", "");
return;
}
const { claimAutomaticLease } = require("./.github/pr-automation/automation-lease");
const gate = JSON.parse(process.env.GATE || "{}");
const decision = await claimAutomaticLease({
github, owner: context.repo.owner, repo: context.repo.repo,
kind: "review", headSha: process.env.HEAD_SHA,
prNumber: Number(process.env.PULL_REQUEST_NUMBER), gate,
runId: Number(process.env.RUN_ID), attempt: Number(process.env.RUN_ATTEMPT),
});
core.setOutput("owner", decision.owner);
core.setOutput("lease", decision.lease ? JSON.stringify(decision.lease) : "");
core.info(JSON.stringify({ event: "pr-automation.review-admission", decision }));
review-pipeline:
name: Run automated review pipeline
needs: [resolve-pr, fork-rate-limit, review-gate, review-admission]
if: >-
always() && !cancelled() &&
needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.review-route == 'true' &&
needs.review-gate.outputs.eligible == 'true' &&
(needs.resolve-pr.outputs.force == 'true' ||
needs.review-admission.outputs.owner == 'true') &&
(needs.resolve-pr.outputs.force == 'true' ||
(needs.fork-rate-limit.result == 'success' &&
needs.fork-rate-limit.outputs.allowed == 'true'))
concurrency:
group: llm-reviewer-pipeline-${{ needs.resolve-pr.outputs.reviewer-pipeline-lane }}
cancel-in-progress: false
queue: max
permissions:
actions: read
checks: read
contents: read
issues: read
pull-requests: read
uses: ./.github/workflows/review-pipeline.yml
with:
pr-number: ${{ needs.resolve-pr.outputs.pr-number }}
head-sha: ${{ needs.resolve-pr.outputs.head-sha }}
base-sha: ${{ needs.resolve-pr.outputs.base-sha }}
gate: ${{ needs.review-gate.outputs.gate }}
lease: ${{ needs.review-admission.outputs.lease }}
specialist-reviewers: ${{ needs.review-gate.outputs.specialist-reviewers }}
required-reviewers: ${{ needs.review-gate.outputs.required-reviewers }}
# Work around actions/runner#4453: environment secrets otherwise resolve empty in reusable jobs.
# This exposes caller-accessible secrets, so the called workflow must remain trusted and not execute PR code.
secrets: inherit
resolve-review-state:
name: Resolve review state
needs: [resolve-pr, fork-rate-limit, review-gate, review-admission, review-pipeline]
if: always() && !cancelled() && needs.resolve-pr.outputs.ok == 'true' &&
needs.resolve-pr.outputs.review-route == 'true' &&
(needs.resolve-pr.outputs.force == 'true' ||
needs.review-admission.outputs.owner == 'true' ||
needs.review-gate.outputs.eligible != 'true')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- name: Download review resolver artifacts
if: needs.review-pipeline.result != 'skipped'
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: review-{final,report,validation,aggregate}-${{ needs.resolve-pr.outputs.head-sha }}
path: review-pipeline
merge-multiple: true
- id: resolve
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }}
BASE_SHA: ${{ needs.resolve-pr.outputs.base-sha }}
GATE: ${{ needs.review-gate.outputs.gate }}
REVIEW_GATE_RESULT: ${{ needs.review-gate.result }}
FORK_RATE_LIMIT: ${{ needs.fork-rate-limit.outputs.quota }}
FORK_RATE_LIMIT_RESULT: ${{ needs.fork-rate-limit.result }}
REVIEWER_REASON: ${{ needs.review-pipeline.outputs.failure-reason }}
REVIEW_PIPELINE_RESULT: ${{ needs.review-pipeline.result }}
SUPERSEDED: ${{ needs.review-pipeline.outputs.superseded }}
FORCE: ${{ needs.resolve-pr.outputs.force }}
LABELS: ${{ needs.resolve-pr.outputs.labels }}
LEASE: ${{ needs.review-admission.outputs.lease }}
REVIEW_MARKER_ID: ${{ github.run_id }}
SUMMARY_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const fs = require("node:fs");
const { resolveReviewState, reviewOutcome } = require("./.github/pr-automation/resolve-state");
const { reviewSkipReasons } = require("./.github/pr-automation/review-skip-summary");
const { parseReport } = require("./.github/pr-automation/review-report");
const { renderReviewReport } = require("./.github/pr-automation/review-report-summary");
const { validateFinalReview } = require("./.github/pr-automation/validate-final-review");
const parse = (value, fallback) => { try { return JSON.parse(value || ""); } catch { return fallback; } };
const force = process.env.FORCE === "true";
const gate = parse(process.env.GATE, {});
const readJson = (file) => {
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
};
const persisted = readJson("review-pipeline/result.json");
const validationContext = readJson("review-pipeline/validation-context.json");
const specialistAggregate = readJson("review-pipeline/validated-specialist-findings.json");
const report = parseReport(fs.existsSync("review-pipeline/review-report.json")
? fs.readFileSync("review-pipeline/review-report.json", "utf8")
: "");
const selectedReviewers = new Set(Array.isArray(gate.specialistReviewers)
? gate.specialistReviewers
: []);
const aggregateReviewers = Array.isArray(specialistAggregate?.reviewers)
? specialistAggregate.reviewers
: [];
const specialistStages = new Map(report.stages
.filter((stage) => stage.id.startsWith("specialist:"))
.map((stage) => [stage.id.slice("specialist:".length), stage]));
const artifactsAgree =
validationContext?.head_sha === process.env.HEAD_SHA &&
specialistAggregate?.head_sha === process.env.HEAD_SHA &&
aggregateReviewers.length === selectedReviewers.size &&
aggregateReviewers.every((reviewer) => {
const stage = specialistStages.get(reviewer.reviewer);
return selectedReviewers.has(reviewer.reviewer) &&
stage?.status === (reviewer.status === "valid" ? "success" : "failed");
}) &&
specialistStages.size === selectedReviewers.size;
const reducedCoverage = artifactsAgree
? aggregateReviewers
.filter((reviewer) => reviewer.status === "failed" &&
specialistStages.get(reviewer.reviewer)?.required !== true)
.map((reviewer) => reviewer.reviewer)
: [];
const failureReasons = report.stages.filter((stage) => stage.status === "failed")
.map((stage) => `${stage.id}: ${stage.reason || stage.category}`);
const reviewerReason = !artifactsAgree
? "review artifacts disagree"
: report.status === "failed"
? failureReasons.join("; ") || process.env.REVIEWER_REASON || "review pipeline failed"
: process.env.REVIEWER_REASON;
const revalidated = artifactsAgree && report.status === "success" && persisted
? validateFinalReview(persisted.raw_output, {
expectedSha: process.env.HEAD_SHA,
changedPaths: validationContext.changed_paths,
changedLines: validationContext.changed_lines,
specialistAggregate,
})
: null;
const reviewer = revalidated?.ok &&
JSON.stringify(revalidated.value) === persisted.output
? revalidated.value
: null;
const lease = parse(process.env.LEASE, null);
const state = !force && process.env.SUPERSEDED === "true"
? {
ok: true, mode: "review", expectedSha: process.env.HEAD_SHA,
superseded: true, lease,
}
: resolveReviewState({
expectedSha: process.env.HEAD_SHA,
labels: gate.labels ?? [],
gate,
contributor: gate.contributor ?? {},
rateLimit: parse(process.env.FORK_RATE_LIMIT, {}),
reviewer,
reviewerReason,
force,
reviewMarkerId: process.env.REVIEW_MARKER_ID,
reducedCoverage,
reviewerValidationContext: validationContext,
specialistAggregate,
requireReviewerContext: true,
reviewAttempted: ["success", "failure"].includes(process.env.REVIEW_PIPELINE_RESULT),
});
if (!force && !state.superseded) state.lease = lease;
core.info(JSON.stringify({
event: "pr-automation.review-state",
decision: {
ok: state.ok, mode: state.mode, failed: state.failed === true,
expectedSha: state.expectedSha, hasReview: (state.comments || [])
.some((comment) => comment.kind === "review"),
},
}));
if (process.env.REVIEW_PIPELINE_RESULT === "skipped") {
const reasons = reviewSkipReasons({
gate,
gateResult: process.env.REVIEW_GATE_RESULT,
rateLimit: parse(process.env.FORK_RATE_LIMIT, null),
rateLimitResult: process.env.FORK_RATE_LIMIT_RESULT,
});
await core.summary
.addHeading("Automated review skipped")
.addRaw("The automated review did not run because:", true)
.addList(reasons)
.write();
} else {
const outcome = reviewOutcome({
reportStatus: report.status, state, reducedCoverage,
});
const rendered = renderReviewReport({
report, outcome, summaryUrl: process.env.SUMMARY_URL, reducedCoverage,
});
if (state.check) {
state.check.title = rendered.title;
state.check.summary = rendered.checkSummary;
state.check.conclusion = outcome === "unavailable" ? "neutral" : "success";
}
await core.summary
.addRaw(rendered.workflowSummary, true)
.write();
}
fs.writeFileSync("review-state.json", JSON.stringify(state));
- name: Upload review state
if: always()
uses: actions/upload-artifact@v7
with:
name: review-state-${{ needs.resolve-pr.outputs.head-sha }}
path: review-state.json
if-no-files-found: error
retention-days: 1
overwrite: true
write-state:
name: Write pull request state
needs: [resolve-pr, classification-gate, classification-admission, deterministic-analysis, fork-rate-limit, semver,
classifier, resolve-classification-state, review-gate, review-admission, review-pipeline, resolve-review-state]
if: always() && !cancelled() && needs.resolve-pr.outputs.pr-number != ''
runs-on: ubuntu-latest
concurrency:
group: pr-automation-mutation-${{ needs.resolve-pr.outputs.pr-number }}
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: write
pull-requests: write
issues: write
checks: write
steps:
- uses: actions/checkout@v6
with:
ref: master
persist-credentials: false
- name: Download review state
if: needs.resolve-review-state.result == 'success'
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: review-state-${{ needs.resolve-pr.outputs.head-sha }}
path: workflow-state/review
- uses: actions/github-script@v8
env:
PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }}
REVIEW_REQUESTED: ${{ needs.resolve-pr.outputs.review-requested }}
OBSERVED_CI_RUN: ${{ needs.resolve-pr.outputs.observed-ci-run }}
CLASSIFICATION_STATE: ${{ needs.resolve-classification-state.outputs.state }}
UPSTREAM_RESULTS: >-
{"classification-gate":"${{ needs.classification-gate.result }}","deterministic-analysis":"${{ needs.deterministic-analysis.result }}","fork-rate-limit":"${{ needs.fork-rate-limit.result }}","semver":"${{ needs.semver.result }}","classifier":"${{ needs.classifier.result }}","resolve-classification-state":"${{ needs.resolve-classification-state.result }}","review-gate":"${{ needs.review-gate.result }}","review-pipeline":"${{ needs.review-pipeline.result }}","resolve-review-state":"${{ needs.resolve-review-state.result }}"}
with:
script: |
const fs = require("node:fs");
const { writeState } = require("./.github/pr-automation/write-state");
const { StaleHeadError } = require("./.github/pr-automation/current-head");
const { reconcileLifecycle } = require("./.github/pr-automation/lifecycle");
const parse = (value) => { try { return JSON.parse(value || ""); } catch { return null; } };
const readState = (file) => {
try { return parse(fs.readFileSync(file, "utf8")); } catch { return null; }
};
const state = readState("workflow-state/review/review-state.json") ??
parse(process.env.CLASSIFICATION_STATE);
const upstreamResults = JSON.parse(process.env.UPSTREAM_RESULTS);
core.info(JSON.stringify({
event: "pr-automation.write-state",
upstreamResults,
selectedState: state ? {
ok: state.ok, mode: state.mode, failed: state.failed === true,
expectedSha: state.expectedSha,
} : null,
}));
try {
if (!state) {
core.info("No normalized result state was available; reconciling lifecycle only");
} else {
await writeState({
github, owner: context.repo.owner, repo: context.repo.repo,
prNumber: Number(process.env.PULL_REQUEST_NUMBER), state,
botLogin: "github-actions[bot]",
reviewRequested: process.env.REVIEW_REQUESTED === "true",
});
}
} catch (error) {
if (!(error instanceof StaleHeadError)) throw error;
core.info("Result publication became stale; reconciling lifecycle only");
} finally {
const lifecycle = await reconcileLifecycle({
github, owner: context.repo.owner, repo: context.repo.repo,
prNumber: Number(process.env.PULL_REQUEST_NUMBER),
observedRun: parse(process.env.OBSERVED_CI_RUN),
});
core.info(JSON.stringify({ event: "pr-automation.lifecycle", lifecycle }));
}
core.info(JSON.stringify({
event: "pr-automation.write-state-complete",
decision: state ? { expectedSha: state.expectedSha, mode: state.mode } : null,
}));