When new commits are pushed, the status job and handle job both fire on the same synchronize event and run in parallel. The event payload's pr.labels reflect the PRE-push state — so if the PR was approved before the push, the status job reads 'approved' from the payload and sets success on the NEW SHA, even though handle is about to (or already did) remove the label. Observed in modelcontextprotocol#2375: label removed at 23:06:29, status set success at 23:06:30 (one second later, from stale payload), merged at 23:06:34. Fix: add invalidate-on-push input (default true, mirroring the main action). On synchronize with invalidation enabled, set pending unconditionally — don't trust the payload labels. The subsequent unlabeled event re-evaluates and stays pending.
99 lines
4 KiB
YAML
99 lines
4 KiB
YAML
name: PR Slash Commands — Merge-Gate Status
|
|
description: >-
|
|
Sets a commit status on the PR head SHA based on approval/hold labels.
|
|
Stays `pending` until the approved label is present, goes `failure` when the
|
|
hold label is present, and `success` when approved-and-not-held. Use this
|
|
instead of an exit-code-based status job so PRs show a yellow pending check
|
|
(not a red failure) while awaiting /lgtm.
|
|
|
|
inputs:
|
|
github-token:
|
|
description: >-
|
|
Token for the commit-status API call. The caller workflow must grant
|
|
this token `statuses: write`.
|
|
required: false
|
|
default: ${{ github.token }}
|
|
approved-label:
|
|
description: Label that marks the PR as approved (set by /lgtm)
|
|
required: false
|
|
default: approved
|
|
hold-label:
|
|
description: Label that blocks merging (set by /hold)
|
|
required: false
|
|
default: do-not-merge/hold
|
|
status-context:
|
|
description: >-
|
|
The commit-status context name. This is what appears in the PR merge box
|
|
and what you mark as a required status check in branch protection.
|
|
required: false
|
|
default: slash-commands/approval
|
|
invalidate-on-push:
|
|
description: >-
|
|
When 'true', a push (synchronize event) forces the status to pending
|
|
regardless of labels. This must match the main slash-commands action's
|
|
invalidate-on-push setting. Without this, on a push the event payload
|
|
still contains the pre-push label state, so this action would set
|
|
success on the new SHA (racing with the handle job removing the label)
|
|
and auto-merge can slip through.
|
|
required: false
|
|
default: 'true'
|
|
|
|
outputs:
|
|
state:
|
|
description: The commit status state that was set (pending, success, failure)
|
|
value: ${{ steps.set.outputs.state }}
|
|
|
|
runs:
|
|
using: composite
|
|
steps:
|
|
- name: Set commit status
|
|
id: set
|
|
uses: actions/github-script@v8
|
|
env:
|
|
APPROVED_LABEL: ${{ inputs.approved-label }}
|
|
HOLD_LABEL: ${{ inputs.hold-label }}
|
|
STATUS_CONTEXT: ${{ inputs.status-context }}
|
|
INVALIDATE_ON_PUSH: ${{ inputs.invalidate-on-push }}
|
|
with:
|
|
github-token: ${{ inputs.github-token }}
|
|
script: |
|
|
const { owner, repo } = context.repo;
|
|
const pr = context.payload.pull_request;
|
|
if (!pr) {
|
|
core.setFailed('slash-commands/status must run on a pull_request(_target) event');
|
|
return;
|
|
}
|
|
const sha = pr.head.sha;
|
|
const approvedLabel = process.env.APPROVED_LABEL;
|
|
const holdLabel = process.env.HOLD_LABEL;
|
|
const statusContext = process.env.STATUS_CONTEXT;
|
|
const invalidateOnPush = process.env.INVALIDATE_ON_PUSH === 'true';
|
|
|
|
// On push, the event payload's labels reflect the PRE-push state.
|
|
// If invalidation is on, the handle job is about to (or already did)
|
|
// remove the approved label in parallel — but we can't see that here.
|
|
// Force pending so auto-merge can't slip through on a stale success.
|
|
// The subsequent unlabeled event will re-evaluate (and stay pending).
|
|
let state, description;
|
|
if (invalidateOnPush && context.payload.action === 'synchronize') {
|
|
state = 'pending';
|
|
description = `New commits pushed — awaiting re-approval via /lgtm`;
|
|
} else {
|
|
const labels = pr.labels.map(l => l.name);
|
|
if (labels.includes(holdLabel)) {
|
|
state = 'failure';
|
|
description = `Blocked by ${holdLabel} — comment /unhold to clear`;
|
|
} else if (labels.includes(approvedLabel)) {
|
|
state = 'success';
|
|
description = `Approved via /lgtm`;
|
|
} else {
|
|
state = 'pending';
|
|
description = `Awaiting /lgtm from a maintainer or CODEOWNER`;
|
|
}
|
|
}
|
|
|
|
await github.rest.repos.createCommitStatus({
|
|
owner, repo, sha, state, description, context: statusContext,
|
|
});
|
|
core.setOutput('state', state);
|
|
core.info(`Set status ${state} on ${sha.slice(0,7)}: ${description}`);
|