Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ outputs:
description: 'SHA-256 canonical digest of the final evaluation receipt.'
summary-markdown:
description: 'Human-readable markdown summary of the evaluation findings.'
target-kind:
description: 'Evaluation target kind (head, merge, or merge_group).'
tested-sha:
description: 'Exact commit SHA evaluated and used as the Check Run head_sha.'
head-sha:
description: 'Exact pull-request head SHA recorded in the evaluation.'

runs:
using: 'node24'
Expand Down
2 changes: 1 addition & 1 deletion dist/action/index.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Exact PR-head binding and tested-SHA visibility — 2026-08-27
- The Action now binds its live API snapshot to the workflow event's exact
`pull_request.head.sha`; a changed live head is rejected with
`GITHUB_TARGET_CHANGED` before evaluation.
- Check Run and step-summary output now shows full `testedSha`, `headSha`,
`baseSha`, target kind, and the head-binding state. Action outputs also expose
`target-kind`, `tested-sha`, and `head-sha` for downstream integrations.
- The consumer guide documents why the check name remains stable and why a
pull-request comment from a QA tool is not automatically verified evidence.

### evaluate --output alias with fail-closed conflicts — 2026-08-23
- `evaluate` accepts `--output` as an alias of `--report`, so the
file-writing flag is uniform across commands. Passing both with different
Expand Down
28 changes: 27 additions & 1 deletion docs/github-action-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ jobs:
# github.token cannot read Administration, so native Rulesets /
# branch-protection snapshots fail closed (correct). A PAT/App token
# with administration:read is required for a complete native-control
# snapshot. beta.5 posts a Check Run for successful evaluations;
# snapshot. The Check Run output shows the full tested SHA and its
# binding to the PR head; a stale event is rejected before evaluation.
# snapshot-rejection Check Runs are included in beta.5.
- name: Run PatchGate Shadow Gate
uses: daichunghy/patchgate@v0.1.0-beta.5
Expand Down Expand Up @@ -139,6 +140,31 @@ consumers should use the tagged Action above, not `uses: ./` after `npm ci`.
| `receipt-digest` | SHA-256 canonical digest of the evaluation receipt |
| `decision-input-digest` | SHA-256 canonical digest of the normalized input snapshot |
| `summary-markdown` | Formatted Markdown summary suitable for step summaries or issue comments |
| `target-kind` | Evaluation target kind (`head`, `merge`, or `merge_group`) |
| `tested-sha` | Exact commit SHA evaluated and used as the Check Run `head_sha` |
| `head-sha` | Exact pull-request head SHA recorded in the evaluation |

### Commit-bound evidence and QAOnFire interoperability

For `pull_request` and `pull_request_target`, the Action binds the live API
snapshot to `github.event.pull_request.head.sha`. If the pull request advanced
between event delivery and API collection, PatchGate returns a non-evaluable
`GITHUB_TARGET_CHANGED` result and does not publish a green result for the
newer revision. A successful Check Run uses the receipt's exact `testedSha` as
its API `head_sha`, and the summary displays the full `testedSha` and `headSha`.

Keep `check-name` stable. Do not append a SHA to it: the required-check identity
is a repository configuration key, while the commit binding belongs in the
Check Run `head_sha` and output.

QAOnFire currently describes its GitHub App as posting a QA report as a pull-
request comment. PatchGate treats that comment as context, not verified check
evidence. The public GitHub App metadata currently lists App ID `3791637`,
`pull_request` and `issue_comment` events, and no `checks:write` permission, so
it cannot currently publish a Check Run for a required-check rule. To make a
QAOnFire result satisfy that rule, the App would need to publish a Check Run
with the actual target SHA and an identifiable source App; the repository could
then configure that App explicitly in `patchgate.yml`.

---

Expand Down
16 changes: 16 additions & 0 deletions docs/implementation-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,22 @@ The authoritative local command is `npm run verify`. The latest project review
and the live/public-state caveats are recorded in
[the G4/G0 continuation audit](reviews/2026-08-20-g4-g0-audit.md).

## External feedback checkpoint — 2026-08-27

The first substantive external reply is recorded in [Discussion
#29](https://github.com/daichunghy/patchgate/discussions/29). `rghetu283`
recommended making the tested commit visible in check output and ensuring that
the result is tied to the actual pull-request head. The follow-up implementation
now checks the workflow event head against the live API head, publishes the
full commit binding in the Check Run summary and exposes the same values as
Action outputs. The detailed evidence record is
[tested-SHA feedback follow-up](reviews/tested-sha-feedback-2026-08-27.md).

This is technical feedback incorporated into the product. It is not a pilot,
external installation, downstream usage or adoption claim. G4 remains open
until a consenting consumer repository runs the Action and the observed result
is reviewed.

## Project-wide review checkpoint — 2026-08-13

The next local build added a redacted `support-bundle` CLI, API fixture byte
Expand Down
106 changes: 106 additions & 0 deletions docs/reviews/tested-sha-feedback-2026-08-27.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Tested-SHA feedback follow-up — 2026-08-27

**Scope:** Investigate the public feedback from `rghetu283` on PatchGate
Discussion #29 and implement the smallest contract-preserving improvement that
can be verified locally.

**Validation level:** current source, public-source research, fixture tests and
repository verification. This record is not evidence of an external pilot or
an installation in QAOnFire.

## Research conclusion

`rghetu283`'s reply to [Discussion
#29](https://github.com/daichunghy/patchgate/discussions/29) identified two
useful practices: show the tested SHA in visible Check Run output and ensure a
successful result is produced only for the actual pull-request head.

The public [QAOnFire site](https://qaonfire.dev/) and [documentation](https://qaonfire.dev/docs.html)
describe QAOnFire as a GitHub App that reads a pull request and posts a
detailed QA report as a pull-request comment. The live GitHub App metadata for
[`qaonfire`](https://github.com/apps/qaonfire) currently resolves to App ID
`3791637`, with `pull_request` and `issue_comment` events and
`contents:write`, `issues:write`, `metadata:read` and `pull_requests:write`
permissions; it does not request `checks:write`. The
[QAOnFire architecture note](https://dev.to/radu_ghetu_84dd251b3979e4/building-qaonfire-how-i-used-prompt-caching-to-make-ai-qa-reports-affordable-3i1p)
describes the worker posting a comment after reading the PR diff; it does not
establish a PatchGate-verifiable Check Run. This means PatchGate must not treat
the current QAOnFire comment as required-check evidence.

GitHub's current documentation states that required checks must succeed on the
latest commit and that earlier-commit checks do not satisfy the requirement.
The Checks API also models the immutable `head_sha` on each Check Run. The
feedback therefore maps to two separate product responsibilities: GitHub's
required-check semantics provide the merge boundary, while PatchGate makes the
selected SHA observable and rejects an event/API target mismatch before it can
produce a result.

## Gap found in the implementation

The evaluator already enforced `testedSha === headSha` for a `head` target and
the adapter already queried checks for the selected SHA. The remaining gaps
were at the Action boundary:

1. The visible summary showed only a seven-character target prefix, not the
full tested SHA or an explicit binding statement.
2. The Action resolved the current pull-request identity through the API but did
not assert that it still matched the event's `pull_request.head.sha`.
3. Downstream steps could read status and digests, but not the exact target
values without parsing the receipt file.

## Implemented changes

- Added optional `expectedHeadSha` to `GitHubSnapshotRequest`.
- The identity resolver now rejects a live head that differs from that
event-bound SHA with `GITHUB_TARGET_CHANGED` and a fail-closed remediation.
- The Action passes the event head into the snapshot request and requires a
non-empty PR event head before making the authenticated snapshot call.
- Check Run and step-summary output now shows full `testedSha`, `headSha`,
`baseSha`, target kind and whether a head-target binding is exact.
- Added `target-kind`, `tested-sha` and `head-sha` Action outputs.
- Check Run delivery now refuses a payload whose `head_sha` differs from the
receipt's `testedSha`, and also refuses an invalid head-target receipt.
- Snapshot-rejection Check Runs identify the event PR head SHA without
presenting the rejected snapshot as a successful evaluation.
- Added regression coverage for event/live head mismatch, full summary output
and Check Run `head_sha`/text binding.
- Documented that a QAOnFire PR comment is context only. PatchGate will not
treat it as verified evidence without a Check Run carrying an identifiable
source and the exact target SHA.

The Check Run name remains stable. A SHA suffix would change the required-check
identity on every commit; the exact binding belongs in `head_sha` and output.

## Acceptance checks

The implementation is accepted only if all of the following remain true:

- a head-target receipt cannot be built with a different `testedSha`;
- an Action event whose head differs from the live API head is rejected before
evaluation;
- a successful Check Run uses the receipt's exact `testedSha` as `head_sha`;
- the visible summary and machine outputs expose the same exact SHA values;
- a rejected snapshot can produce only the documented neutral/non-ready result;
- `npm run verify` passes, including the bundled Action and consumer fixture.

## Open dependency

There is no public QAOnFire source repository or Check Run contract available
in the current research pass, and no permission has been granted to install an
external App or change that project. A true end-to-end success claim therefore
requires Radu or an authorized maintainer to run the updated Action in a
consenting repository, share the resulting Check Run/receipt, and confirm that
the observed SHA and workflow behavior meet their use case. The current App
permission boundary means that run can validate interoperability and visible
SHA binding, but cannot make a QAOnFire comment itself satisfy a required-check
rule.

## Sources

- [PatchGate Discussion #29](https://github.com/daichunghy/patchgate/discussions/29)
- [QAOnFire](https://qaonfire.dev/)
- [QAOnFire documentation](https://qaonfire.dev/docs.html)
- [QAOnFire GitHub App](https://github.com/apps/qaonfire)
- [QAOnFire architecture note](https://dev.to/radu_ghetu_84dd251b3979e4/building-qaonfire-how-i-used-prompt-caching-to-make-ai-qa-reports-affordable-3i1p)
- [GitHub required-status-check troubleshooting](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks)
- [GitHub Checks API](https://docs.github.com/en/rest/checks/runs)
33 changes: 25 additions & 8 deletions src/action/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,14 @@ export function formatMarkdownSummary(receipt: ContributionReceipt): string {
const icon = statusEmoji[receipt.final.status] ?? "ℹ️";
const title = `### ${icon} PatchGate Review Gate: \`${receipt.final.status.toUpperCase()}\``;

const repoInfo = `**Repository:** \`${receipt.repository.owner}/${receipt.repository.name}\` PR #${receipt.repository.pullRequest}
**Target Commit:** \`${receipt.revisions.testedSha.slice(0, 7)}\` | **Base SHA:** \`${receipt.revisions.baseSha.slice(0, 7)}\`
const repoInfo = `**Repository:** \`${receipt.repository.owner}/${receipt.repository.name}\` PR #${receipt.repository.pullRequest}<br>
**Target Kind:** \`${receipt.revisions.targetKind}\`<br>
**Tested SHA:** \`${markdownCell(receipt.revisions.testedSha)}\`<br>
**PR Head SHA:** \`${markdownCell(receipt.revisions.headSha)}\`<br>
**Base SHA:** \`${markdownCell(receipt.revisions.baseSha)}\`<br>
**Commit Binding:** ${receipt.revisions.targetKind === "head"
? receipt.revisions.testedSha === receipt.revisions.headSha ? "✅ `testedSha` equals `headSha`" : "❌ `testedSha` does not equal `headSha`"
: "ℹ️ tested revision is the declared merge target; PR head is recorded separately"}<br>
**Policy Digest:** \`${receipt.policyDigest.slice(0, 18)}...\`
**Receipt Digest:** \`${receipt.receiptDigest.slice(0, 18)}...\``;

Expand Down Expand Up @@ -206,9 +212,10 @@ export async function runAction(env: NodeJS.ProcessEnv = process.env): Promise<n
const pullNumber = eventPayload.pull_request?.number;
const owner = eventPayload.repository?.owner?.login || eventPayload.repository?.owner?.name;
const name = eventPayload.repository?.name;
const eventHeadSha = eventPayload.pull_request?.head?.sha;

if (pullNumber === undefined || owner === undefined || name === undefined) {
console.error("PatchGate Action: Pull request number or repository identity is missing from event payload.");
if (typeof pullNumber !== "number" || !Number.isInteger(pullNumber) || pullNumber < 1 || typeof owner !== "string" || owner.length === 0 || typeof name !== "string" || name.length === 0 || typeof eventHeadSha !== "string" || eventHeadSha.trim().length === 0) {
console.error("PatchGate Action: Pull request number, repository identity, or event head SHA is missing or invalid.");
return 2;
}

Expand All @@ -229,6 +236,7 @@ export async function runAction(env: NodeJS.ProcessEnv = process.env): Promise<n
owner,
name,
pullNumber,
expectedHeadSha: eventHeadSha,
eventKind: "pull_request",
targetKind: "head",
};
Expand All @@ -242,7 +250,7 @@ export async function runAction(env: NodeJS.ProcessEnv = process.env): Promise<n
if (diagnostic.remediation) {
console.error(`Remediation: ${diagnostic.remediation}`);
}
const errorMarkdown = `### ❌ PatchGate Snapshot Rejected\n\n**Diagnostic:** \`${diagnostic.id}\` \n**Message:** ${diagnostic.message} \n**Remediation:** ${diagnostic.remediation ?? "None"}\n`;
const errorMarkdown = `### ❌ PatchGate Snapshot Rejected\n\n**Event PR Head SHA:** \`${markdownCell(eventHeadSha)}\` \n**Diagnostic:** \`${diagnostic.id}\` \n**Message:** ${diagnostic.message} \n**Remediation:** ${diagnostic.remediation ?? "None"}\n`;
setActionOutput("status", "evidence_missing", env);
setActionOutput("summary-markdown", errorMarkdown, env);
appendStepSummary(errorMarkdown, env);
Expand Down Expand Up @@ -288,6 +296,9 @@ export async function runAction(env: NodeJS.ProcessEnv = process.env): Promise<n
setActionOutput("receipt-path", resolvedReportPath, env);
setActionOutput("decision-input-digest", receipt.decisionInputDigest, env);
setActionOutput("receipt-digest", receipt.receiptDigest, env);
setActionOutput("target-kind", receipt.revisions.targetKind, env);
setActionOutput("tested-sha", receipt.revisions.testedSha, env);
setActionOutput("head-sha", receipt.revisions.headSha, env);
setActionOutput("summary-markdown", summaryMarkdown, env);
appendStepSummary(summaryMarkdown, env);

Expand Down Expand Up @@ -325,7 +336,7 @@ export interface CheckRunParams {
headSha: string;
checkName: string;
status: FinalStatus;
receipt: Pick<ContributionReceipt, "receiptDigest" | "decisionInputDigest" | "evaluatedAt">;
receipt: Pick<ContributionReceipt, "receiptDigest" | "decisionInputDigest" | "evaluatedAt" | "revisions">;
summaryMarkdown: string;
token: string;
}
Expand Down Expand Up @@ -387,6 +398,12 @@ async function deliverCheckRun(params: { owner: string; name: string; headSha: s
}

export async function upsertCheckRun(params: CheckRunParams, fetchImpl: typeof fetch = fetch): Promise<void> {
if (params.headSha !== params.receipt.revisions.testedSha) {
throw new Error("PatchGate Action: Check Run head SHA must equal the receipt tested SHA.");
}
if (params.receipt.revisions.targetKind === "head" && params.receipt.revisions.testedSha !== params.receipt.revisions.headSha) {
throw new Error("PatchGate Action: A head-target Check Run requires tested SHA to equal the PR head SHA.");
}
const conclusionMap: Record<FinalStatus, "success" | "failure" | "action_required" | "neutral"> = {
ready_for_review: "success",
blocked: "failure",
Expand All @@ -404,7 +421,7 @@ export async function upsertCheckRun(params: CheckRunParams, fetchImpl: typeof f
output: {
title: `PatchGate: ${params.status.toUpperCase().replace(/_/g, " ")}`,
summary: params.summaryMarkdown,
text: `Receipt Digest: \`${params.receipt.receiptDigest}\`\nDecision Input Digest: \`${params.receipt.decisionInputDigest}\`\nEvaluated At: ${params.receipt.evaluatedAt}`,
text: `Target kind: ${params.receipt.revisions.targetKind}\nTested SHA: \`${params.receipt.revisions.testedSha}\`\nPR head SHA: \`${params.receipt.revisions.headSha}\`\nReceipt Digest: \`${params.receipt.receiptDigest}\`\nDecision Input Digest: \`${params.receipt.decisionInputDigest}\`\nEvaluated At: ${params.receipt.evaluatedAt}`,
},
};
await deliverCheckRun(params, payload, fetchImpl);
Expand All @@ -430,7 +447,7 @@ export async function upsertRejectionCheckRun(params: RejectionCheckRunParams, f
output: {
title: "PatchGate: SNAPSHOT REJECTED",
summary: params.summaryMarkdown,
text: `Diagnostic: ${params.diagnostic.id}\nRemediation: ${params.diagnostic.remediation ?? "None"}`,
text: `Target kind: head\nEvent PR head SHA: \`${params.headSha}\`\nDiagnostic: ${params.diagnostic.id}\nRemediation: ${params.diagnostic.remediation ?? "None"}`,
},
};
await deliverCheckRun(params, payload, fetchImpl);
Expand Down
Loading