Skip to content

Squad review record for PR #2162 #4630

Squad review record for PR #2162

Squad review record for PR #2162 #4630

name: Record squad review (self-attested)
# Squad pre-PR review RECORD.
#
# ⚠️ READ THIS BEFORE TRUSTING THE `squad/pre-pr-verdict` STATUS.
#
# THIS IS NOT INDEPENDENT REVIEW. IT PROVIDES NO SEPARATION OF DUTIES.
#
# Every squad agent — Bishop, Hicks, Vasquez, and every author agent — runs
# under the repository owner's authority and posts through the owner's token.
# A "reviewer agent approving an author agent" is therefore the owner approving
# the owner's own work. The reviewer-is-not-the-author rule below is a QUALITY
# HEURISTIC (a second agent with fresh context catches more than the author
# re-reading its own output), not an independence guarantee. A green
# `squad/pre-pr-verdict` means "a reviewer agent examined this exact commit and
# recorded its findings", self-attested. It does NOT mean "a second party
# approved this". Do not read it as sign-off, authorisation, or an audit
# control that satisfies four-eyes.
#
# WHY IT IS SHAPED THIS WAY: the previous version required a *non-author
# repository administrator* to dispatch it by hand. This repository has exactly
# one administrator, who authors effectively every PR, so that guard could never
# be satisfied — its entire run history was failures (issue #1310). Parker's
# analysis on that issue established that every fix preserving genuine
# separation of duties requires a second human, a machine identity controlled by
# someone other than the owner, or a real independent CI adjudicator — none of
# which exist here. The owner has accepted self-attested agent review for
# single-maintainer operation (issue #1310, option 3). Nothing here launders the
# owner's identity through a bot hop to fake independence; the gate states what
# it is instead.
#
# WHAT THIS RECORD GENUINELY GIVES YOU, even though review is self-attested:
# * SHA BINDING — a record is valid only for the exact head SHA it names. Push
# again and it goes stale and the gate fails. This really does prevent
# review-then-push-more, and it is the strongest control here.
# * PRESENCE — the gate fails when no review happened at all, which catches
# the real failure mode of a PR merging with zero examination.
# * AUDIT TRAIL — which reviewer agent ran, which SHA it examined, what it
# concluded.
# * LEGIBLE FAILURE — the status names the exact failing condition so a
# blocked session acts instead of parking indefinitely.
#
# HOW TO RECORD A REVIEW — post a PR comment containing these three lines:
#
# <!-- squad-verdict -->
# Squad-Reviewer: bishop
# Squad-Verdict: APPROVE
# Squad-Head-SHA: 0123456789abcdef0123456789abcdef01234567
#
# * `Squad-Reviewer` is a squad identity, validated against the repository's
# `squad:{member}` labels. It should not be the squad member who authored the
# PR (resolved from `Squad-Author:` in the PR body, then the `squad:` label
# on the linked issue, then the head branch name). Again: quality heuristic,
# not independence.
# * `Squad-Verdict` is APPROVE (the reviewer agent found nothing blocking) or
# REQUEST_CHANGES (it did). It is that agent's recorded conclusion, not an
# authorisation.
# * `Squad-Head-SHA` must equal the PR's live head SHA at evaluation time. A
# record against an older SHA is stale, does not count, and can never
# displace a reviewer's record on the current head.
# * The `<!-- squad-verdict -->` marker is REQUIRED. Fenced code blocks
# (including an unterminated fence, which GitHub renders as code to the end
# of the comment), quoted (`>`) lines, and every other HTML comment are
# stripped before parsing. Prose that illustrates, quotes, or hides the
# format is therefore not a binding record — what the gate counts is what a
# human reading the thread can see. Each field must appear exactly once in
# the remaining text.
# * The commenting account must be AUTHENTICATED as holding repository write
# access. Both repositories are PUBLIC, so any GitHub user can comment on a
# PR with no permission at all, and Ralph merges autonomously using the
# OWNER's write access — a forgeable record would lend those privileges to
# whoever forged it. Permission is resolved live through the collaborator
# permission API (`admin`/`maintain`/`write` only; `read`, which is what a
# non-collaborator returns on a public repo, and `triage` are rejected).
# `author_association` is a cheap pre-filter, never the authorisation.
# Lookups FAIL CLOSED: an error, rate limit, or unexpected shape rejects the
# record. Identity always comes from the API-supplied account, never from
# the comment text. No bot identity is allowlisted — that would re-create
# the bot-hop laundering rejected above.
# * PUT THE RECORD BLOCK FIRST in the comment. Sanitisation fails closed: an
# unterminated `<!--` or code fence earlier in the comment hides everything
# after it, exactly as GitHub renders it, which would drop your record.
# * FORK PRs never accept an agent record: anyone can open one on a public
# repository, so a record there could only be self-asserted by an
# unauthenticated party. An administrator's native GitHub approval at the
# current head is still honoured on that path, evaluated by the same code —
# it reads only API-supplied logins and the live head SHA.
#
# REVIEWER COUNT follows .github/copilot-instructions.md § "Documentation-Only
# Changes: One Reviewer": documentation-only PRs need one record, everything
# else needs the bishop/hicks/vasquez panel.
#
# OWNER OVERRIDE: on an in-scope PR a repository administrator satisfies the gate
# unconditionally, either by approving through GitHub's native review UI at the
# current head, or by posting a record whose `Squad-Reviewer` is their own GitHub
# login. Only each administrator's MOST RECENT decisive review at that head
# counts (APPROVED / CHANGES_REQUESTED / DISMISSED; COMMENTED is not decisive),
# so a later change request outranks an earlier approval on the same commit. The
# owner is never locked out of their own repository.
#
# The override does not apply to an out-of-scope PR, and does not need to: it
# exists to clear a BLOCKED status, and an unlabelled PR has no such status to
# clear. NOT_APPLICABLE is green, so an administrator can merge it directly at
# any time. What the override cannot do there is authorise the UNATTENDED merger,
# which by design only touches labelled PRs — to hand a PR to Ralph, label it.
#
# REMOVING THE LABEL IS NOT A HOLD. On a non-fork PR whose author resolves to a
# roster member, this workflow re-applies the scope label on the next run, so
# stripping it does not park the PR. That is the safe direction — it re-imposes
# the review gate rather than bypassing it — but it means label removal is the
# wrong tool for holding work back. To stop the unattended merger, record a
# CHANGES_REQUESTED verdict or mark the PR draft.
#
# The job itself always succeeds; the gate result is the `squad/pre-pr-verdict`
# commit status. Keeping the run conclusion at `success` is what lets
# scripts/ci/verify-squad-verdict.mjs treat the run as a provenance record —
# provenance meaning "this status really was written by this workflow", not
# "someone independent approved this".
run-name: "Squad review record for PR #${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }}"
on:
pull_request_target:
types:
- opened
- synchronize
- reopened
- ready_for_review
- edited
# Scope is carried by the `squad` label, so the status must be
# re-evaluated whenever that label is applied or removed.
- labeled
- unlabeled
issue_comment:
types: [created, edited, deleted]
pull_request_review:
types: [submitted, edited, dismissed]
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to re-evaluate
required: true
type: string
permissions:
contents: read
issues: read
# write, not read: this workflow applies the `squad` scope label itself. A
# separate labelling workflow cannot work here — GitHub does not start new
# workflow runs from events caused by the default GITHUB_TOKEN, so its
# `labeled` event would never re-trigger the evaluation that depends on it.
pull-requests: write
statuses: write
concurrency:
group: >-
squad-verdict-${{ github.event.pull_request.number
|| github.event.issue.number || inputs.pr_number }}
cancel-in-progress: true
jobs:
record:
name: Record SHA-pinned review
# issue_comment also fires for plain issues; only PR conversations qualify.
if: >-
github.event_name != 'issue_comment'
|| github.event.issue.pull_request != null
runs-on: ubuntu-latest
steps:
# Checks out the default branch, never the pull request head, so a PR can
# never supply the logic that judges it. pull_request_target already runs
# the workflow definition from the default branch; this keeps the helper
# module on the same footing.
- name: Check out gate logic from the default branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
sparse-checkout: scripts/ci
- name: Evaluate squad verdict evidence
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
PR_NUMBER: >-
${{ github.event.pull_request.number || github.event.issue.number
|| inputs.pr_number }}
with:
script: |
const gate = await import(
`${process.env.GITHUB_WORKSPACE}/scripts/ci/squad-verdict-gate.mjs`
);
const prNumberInput = String(process.env.PR_NUMBER ?? '');
if (!/^[1-9]\d*$/.test(prNumberInput)) {
core.setFailed('Could not resolve a pull request number.');
return;
}
const prNumber = Number.parseInt(prNumberInput, 10);
const owner = context.repo.owner;
const repo = context.repo.repo;
const repository = `${owner}/${repo}`;
const { data: pull } = await github.rest.pulls.get({
owner, repo, pull_number: prNumber,
});
if (pull.state !== 'open') {
core.notice(`PR #${prNumber} is not open; nothing to gate.`);
return;
}
const headSha = String(pull.head?.sha ?? '').toLowerCase();
const targetUrl =
`${context.serverUrl}/${repository}/actions/runs/${context.runId}`;
const postStatus = (state, description) =>
github.rest.repos.createCommitStatus({
owner, repo, sha: headSha, state,
context: gate.verdictContext,
description, target_url: targetUrl,
});
// Labels on the issues this PR closes are the most reliable author
// signal, so a failure here degrades author resolution rather than
// failing the run.
async function loadLinkedIssueLabels() {
try {
const linked = await github.graphql(`
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 20) {
nodes { number labels(first: 50) { nodes { name } } }
}
}
}
}`, { owner, repo, number: prNumber });
return (linked.repository.pullRequest
.closingIssuesReferences.nodes ?? [])
.flatMap((node) => (node.labels?.nodes ?? []).map((l) => l.name));
} catch (error) {
core.warning(`Could not read linked issues: ${error.message}`);
return [];
}
}
const isFork = pull.head.repo?.full_name !== repository;
// Scope check first: the gate covers squad-authored PRs, marked by
// the `squad` label. Dependency bumps and ad-hoc human PRs are not
// agent output and get NOT_APPLICABLE instead of an unclearable
// BLOCKED. Checked before the fork branch so an out-of-scope fork PR
// is reported as out of scope rather than as a fork needing an
// administrator.
//
// Labelling happens HERE rather than in a separate workflow. A
// dedicated labeller would have to rely on its `labeled` event
// waking this workflow up, and GitHub does not start new workflow
// runs from events caused by the default GITHUB_TOKEN — so the
// re-evaluation would never fire, and the two workflows would race
// on `opened`. Doing both in one run removes the race and the token
// problem together.
//
// Safe as opt-in ONLY because Ralph will not auto-merge an
// unlabelled PR; see gate.squadScopeLabel and ralph-reference.md.
const roster = gate.rosterFromLabels(
(await github.paginate(github.rest.issues.listLabelsForRepo,
{ owner, repo, per_page: 100 })).map((label) => label.name),
);
let squadLabeled = gate.hasSquadScopeLabel(pull.labels ?? []);
const author = gate.resolveAuthorMembers({
prBody: pull.body ?? '',
branchName: pull.head?.ref ?? '',
linkedIssueLabels: await loadLinkedIssueLabels(),
roster,
});
if (!squadLabeled &&
gate.canAutoScope({ authorMembers: author.members, roster, isFork })) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber,
labels: [gate.squadScopeLabel],
});
squadLabeled = true;
core.notice(
`Applied '${gate.squadScopeLabel}': resolved squad author ` +
`${[...author.members].join('+')} from ${author.source}.`,
);
} catch (error) {
// Labelling is a convenience, not the gate. If it fails the PR
// stays out of scope and reports NOT_APPLICABLE, which is the
// safe direction: a human owns the merge.
core.warning(`Could not apply the scope label: ${error.message}`);
}
}
if (!squadLabeled) {
const outOfScope = gate.evaluateGate({ headSha, squadLabeled: false });
await postStatus(outOfScope.state, outOfScope.description);
core.notice(
`squad/pre-pr-verdict NOT_APPLICABLE: ${outOfScope.reason}. ` +
`Add the '${gate.squadScopeLabel}' label if this is squad-authored work.`,
);
return;
}
// Author authentication. Both repositories are PUBLIC, so any GitHub
// user can comment on a PR with no permission at all. Ralph merges
// autonomously using the owner's write access, so a forgeable record
// would lend the owner's privileges to whoever forged it. Resolve the
// live collaborator permission for every commenter and fail closed on
// any error, rate limit, or unexpected shape.
const permissionCache = new Map();
async function permissionOf(login) {
if (typeof login !== 'string' || login === '') return 'unresolved';
const key = login.toLowerCase();
if (permissionCache.has(key)) return permissionCache.get(key);
let permission = 'unresolved';
try {
const { data } =
await github.rest.repos.getCollaboratorPermissionLevel({
owner, repo, username: login,
});
permission = typeof data?.permission === 'string'
? data.permission
: 'unresolved';
} catch (error) {
core.warning(
`Could not verify repository permission for ${login}: ` +
`${error.message}. Failing closed; any record from this ` +
'account is rejected.',
);
}
permissionCache.set(key, permission);
return permission;
}
const isAdmin = async (login) =>
gate.hasAdminAccess(await permissionOf(login));
const canWrite = async (login) =>
gate.hasWriteAccess(await permissionOf(login));
const decisiveReviewStates = ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'];
async function loadReviews() {
const raw = await github.paginate(github.rest.pulls.listReviews,
{ owner, repo, pull_number: prNumber, per_page: 100 });
const reviews = [];
for (const review of raw) {
// Permission is resolved for every decisive state, not just
// APPROVED: an administrator's later CHANGES_REQUESTED has to be
// able to outrank their own earlier approval on the same commit.
reviews.push({
id: review.id,
state: review.state,
submittedAt: review.submitted_at,
commitId: review.commit_id,
login: review.user?.login ?? '',
isAdmin: decisiveReviewStates.includes(review.state)
? await isAdmin(review.user?.login)
: false,
});
}
return reviews;
}
if (isFork) {
// Fork PRs never accept an agent record: anyone can open one on a
// public repository, so a record there could only be self-asserted
// by an unauthenticated party. An administrator's native GitHub
// approval is still honoured, and is evaluated by the same code
// path as everywhere else — it reads only API-supplied logins and
// the live head SHA, never fork-controlled input, so fork-PR
// authorisation stays in code rather than falling back to prose.
const forkResult = gate.evaluateGate({
headSha, reviews: await loadReviews(), squadLabeled: true,
});
if (forkResult.override === 'github-review') {
await postStatus(forkResult.state, forkResult.description);
core.notice(`squad/pre-pr-verdict ${forkResult.description}`);
return;
}
await postStatus(
'failure',
`BLOCKED @ ${headSha.slice(0, 12)}: fork PR needs a repository administrator`,
);
core.warning(
'Fork pull request: agent review records are not accepted. ' +
'A repository administrator must review and approve directly.',
);
return;
}
const [rawComments, fileData] = await Promise.all([
github.paginate(github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 }),
github.paginate(github.rest.pulls.listFiles,
{ owner, repo, pull_number: prNumber, per_page: 100 }),
]);
const changedPaths = fileData.map((file) => file.filename);
// Resolve real repository permission for every account that posted
// something shaped like a verdict, and flag the owner override.
const comments = [];
for (const raw of rawComments) {
const shape = gate.parseVerdictComment({
...raw, squadWriteAccess: true,
});
if (!shape) {
comments.push(raw);
continue;
}
const login = shape.commenter;
const comment = { ...raw, squadWriteAccess: await canWrite(login) };
if (comment.squadWriteAccess &&
shape.reviewer === gate.normalizeMember(login) &&
await isAdmin(login)) {
comment.squadAdminOverride = true;
}
comments.push(comment);
}
const reviews = await loadReviews();
// Sync-carry-forward exemption (issue #1633, "Option A"). A
// record pinned to an old head SHA stays valid at the current
// head when (1) the old SHA is a strict ancestor of the current
// head, (2) the PR's own diff against the base branch — NOT the
// raw commit list, which always contains the sync merge commit
// itself even for a clean sync — is byte-for-byte unchanged
// between the old SHA and the current head, AND (3) every commit
// introduced since review that isn't already reachable from base
// introduces nothing beyond what a clean merge of its own two
// parents would produce. (3) exists because (1)+(2) alone would
// let an author push a commit that changes the PR and a later
// commit that reverts it land back on the same final diff while
// still containing real author work in the range — exactly what
// the SHA pin exists to catch.
//
// (3) is NOT "the commit's own diff against its first parent is
// empty" — GitHub's single-commit endpoint diffs a merge commit
// against its first parent only, and that is naturally non-empty
// for a real sync merge (it necessarily includes everything
// pulled in from the base side; verified against this repo's own
// history, where a known clean sync merge reports a non-empty
// `files` list this way). Instead, for a two-parent merge commit
// `[p1, p2]`, `compare(p1...p2)` is a three-dot compare pivoting
// on p1/p2's own merge base, so its `files` are exactly "what p2
// contributes beyond its common history with p1" — what a clean,
// no-conflict merge of p2 into p1 would add. If the merge
// commit's own diff matches that fingerprint, it added nothing
// beyond a clean merge; if it differs (a conflict resolved by
// changing logic, most obviously), it's disqualifying. A commit
// with any other parent count (an ordinary single-parent commit,
// i.e. real author work, or a rare octopus merge this check
// can't validate) always disqualifies.
const distinctOldShas = new Set();
for (const raw of rawComments) {
const shape = gate.parseVerdictComment({ ...raw, squadWriteAccess: true });
if (shape && shape.headSha !== headSha) {
distinctOldShas.add(shape.headSha);
}
}
const baseRef = pull.base?.ref ?? '';
// The compare endpoint silently caps `files` (no in-band
// truncation flag), so a diff at or beyond that cap can never be
// proven unchanged and must fail closed. 300 is GitHub's
// documented ceiling for this endpoint. Its `commits` array is
// similarly capped, but DOES expose `total_commits`, so an
// incomplete commit list is detectable and must also fail closed.
const compareFilesCap = 300;
const commitsIncomplete = (compare) =>
(compare.total_commits ?? (compare.commits ?? []).length) >
(compare.commits ?? []).length;
const carriedShas = new Set();
for (const oldSha of distinctOldShas) {
try {
const { data: ancestryCompare } =
await github.rest.repos.compareCommitsWithBasehead({
owner, repo, basehead: `${oldSha}...${headSha}`,
});
const { data: reviewedCompare } =
await github.rest.repos.compareCommitsWithBasehead({
owner, repo, basehead: `${baseRef}...${oldSha}`,
});
const { data: baseAheadCompare } =
await github.rest.repos.compareCommitsWithBasehead({
owner, repo, basehead: `${baseRef}...${headSha}`,
});
const reviewedFiles = reviewedCompare.files ?? [];
const currentFiles = baseAheadCompare.files ?? [];
let nonBaseCommitsIntroduceNoExtraContent = false;
if (!commitsIncomplete(ancestryCompare) && !commitsIncomplete(baseAheadCompare)) {
const aheadOfBaseShas = new Set(
(baseAheadCompare.commits ?? []).map((c) => c.sha),
);
const nonBaseShas = (ancestryCompare.commits ?? [])
.map((c) => c.sha)
.filter((sha) => aheadOfBaseShas.has(sha));
let allClean = true;
for (const nonBaseSha of nonBaseShas) {
const { data: singleCommit } = await github.rest.repos.getCommit({
owner, repo, ref: nonBaseSha,
});
const parentShas = (singleCommit.parents ?? []).map((p) => p.sha);
if (parentShas.length !== 2) {
// Ordinary single-parent commit (real author work) or
// an octopus merge this check can't validate.
allClean = false;
break;
}
const singleCommitFiles = singleCommit.files ?? [];
// GitHub's single-commit endpoint exposes `stats.total`
// (additions + deletions summed across the WHOLE commit,
// independent of the 300-file cap on `files`) — verified
// empirically (a real commit with >300 files still
// reports its true total there). Summing the returned
// `files` and comparing to `stats.total` catches most
// truncation, but is not sufficient alone: a truncated
// page whose missing files happen to be pure renames
// (0 additions, 0 deletions) would still sum-match. Keep
// the length-vs-cap heuristic as a second, independent
// check — either signal firing means the list can't be
// trusted.
const singleCommitFilesSum = singleCommitFiles.reduce(
(sum, f) => sum + (f.additions ?? 0) + (f.deletions ?? 0), 0,
);
const singleCommitFilesTruncated =
singleCommitFiles.length >= compareFilesCap ||
(typeof singleCommit.stats?.total === 'number' &&
singleCommitFilesSum !== singleCommit.stats.total);
const { data: parentsCompare } =
await github.rest.repos.compareCommitsWithBasehead({
owner, repo, basehead: `${parentShas[0]}...${parentShas[1]}`,
});
const parentsCompareFiles = parentsCompare.files ?? [];
if (
singleCommitFilesTruncated ||
parentsCompareFiles.length >= compareFilesCap
) {
// Either side of the equality check may be incomplete
// — a capped/truncated list can never prove the
// fingerprints match (or genuinely differ), so this
// can't be trusted as proof of a clean merge.
allClean = false;
break;
}
if (
gate.diffFingerprint(singleCommitFiles) !==
gate.diffFingerprint(parentsCompareFiles)
) {
// The merge commit's own diff differs from what
// merging its two parents alone would produce — it
// added something beyond a clean merge.
allClean = false;
break;
}
}
nonBaseCommitsIntroduceNoExtraContent = allClean;
}
const eligible = gate.isCarriedAcrossSync({
recordAncestryStatus: ancestryCompare.status,
reviewedDiffFiles: reviewedFiles,
currentDiffFiles: currentFiles,
filesMayBeTruncated:
reviewedFiles.length >= compareFilesCap ||
currentFiles.length >= compareFilesCap,
nonBaseCommitsIntroduceNoExtraContent,
});
if (eligible) {
carriedShas.add(oldSha);
core.notice(
`Carrying review record at ${oldSha.slice(0, 12)} forward to ` +
`${headSha.slice(0, 12)}: the PR's own diff against ` +
`${baseRef} is unchanged since review, and every new ` +
'non-base commit since then is a clean merge introducing ' +
'nothing beyond its own two parents.',
);
}
} catch (error) {
// Fails closed: a compare failure (rate limit, unknown SHA,
// repository not fetched, etc.) never carries a record
// forward — worst case is an unnecessary re-review, not a
// skipped one.
core.warning(
`Could not evaluate sync carry-forward for ${oldSha}: ${error.message}. ` +
'Treating the record as superseded.',
);
}
}
const result = gate.evaluateGate({
headSha,
changedPaths,
comments,
reviews,
roster,
authorMembers: author.members,
authorSource: author.source,
squadLabeled: true,
carriedShas,
});
await postStatus(result.state, result.description);
const summary = core.summary
.addHeading('Squad review record (self-attested)')
.addRaw(
'\n> ⚠️ This is **not** independent review and provides **no** ' +
'separation of duties. Every squad agent runs under the ' +
'repository owner\'s authority, so this records that a reviewer ' +
'agent examined this exact commit — it is not approval by a ' +
'second party. Only an `APPROVE (owner)` result is an ' +
'authorisation by a distinct principal.\n\n',
)
.addTable([
[{ data: 'Field', header: true }, { data: 'Value', header: true }],
['PR', `#${prNumber}`],
['Head SHA', headSha],
['Result', result.passed
? (result.override
? 'APPROVE (owner)'
: result.carried?.length > 0
? 'REVIEWED (self-attested, carried across sync)'
: 'REVIEWED (self-attested)')
: 'BLOCKED'],
['Reason', result.reason],
['Self-attested review records', result.approvals.join(', ') || '(none)'],
['Expected reviewers', result.requiredMembers.join(', ') || 'n/a'],
['Stale records', result.stale
.map((r) => `${r.reviewer} @ ${r.headSha}`).join(', ') || '(none)'],
]);
for (const note of result.notes) {
summary.addRaw(`\n- ${note}`);
}
await summary.write();
if (result.passed) {
core.notice(
`squad/pre-pr-verdict ${result.override ? 'APPROVE (owner)' : 'REVIEWED (self-attested)'}: ` +
`${result.reason}`,
);
} else {
core.warning(`squad/pre-pr-verdict BLOCKED: ${result.reason}`);
}