Skip to content

Commit 5793957

Browse files
feat: Add validate-pr composite action
Add a composite action that validates non-maintainer PRs against contribution guidelines. Checks that PRs reference a GitHub issue with prior maintainer discussion, and enforces draft status on all new PRs. Extracts the validation logic into standalone JS scripts for testability, matching the pattern used by the danger action. Previously this workflow was being copy-pasted across SDK repos (327 lines each). Now each repo only needs a ~15-line caller workflow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 705635b commit 5793957

File tree

5 files changed

+431
-0
lines changed

5 files changed

+431
-0
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ Runs DangerJS on Pull Requests with a pre-configured set of rules.
1616

1717
**[📖 View full documentation →](danger/README.md)**
1818

19+
### Validate PR
20+
21+
Validates non-maintainer PRs against contribution guidelines and enforces draft status.
22+
23+
**[📖 View full documentation →](validate-pr/README.md)**
24+
1925
## Legacy Reusable Workflows (v2)
2026

2127
> ⚠️ **Deprecated**: Reusable workflows have been converted to composite actions in v3. Please migrate to the composite actions above.

validate-pr/README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Validate PR
2+
3+
Validates non-maintainer pull requests against contribution guidelines.
4+
5+
## What it does
6+
7+
1. **Validates issue references** — Non-maintainer PRs must reference a GitHub issue where the PR author and a maintainer have discussed the approach. PRs that don't meet this requirement are automatically closed with a descriptive comment.
8+
2. **Enforces draft status** — All PRs must start as drafts. Non-draft PRs are automatically converted and labeled.
9+
10+
Maintainers (users with `admin` or `maintain` role) are exempt from all checks.
11+
12+
## Usage
13+
14+
Create `.github/workflows/validate-pr.yml` in your repository:
15+
16+
```yaml
17+
name: Validate PR
18+
19+
on:
20+
pull_request_target:
21+
types: [opened, reopened]
22+
23+
jobs:
24+
validate-pr:
25+
runs-on: ubuntu-24.04
26+
permissions:
27+
pull-requests: write
28+
steps:
29+
- uses: getsentry/github-workflows/validate-pr@v3
30+
with:
31+
app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }}
32+
private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }}
33+
```
34+
35+
## Inputs
36+
37+
| Input | Required | Description |
38+
|-------|----------|-------------|
39+
| `app-id` | Yes | GitHub App ID for the SDK Maintainer Bot |
40+
| `private-key` | Yes | GitHub App private key for the SDK Maintainer Bot |
41+
42+
## Outputs
43+
44+
| Output | Description |
45+
|--------|-------------|
46+
| `was-closed` | `'true'` if the PR was closed by validation, unset otherwise |
47+
48+
## Validation rules
49+
50+
### Issue reference check
51+
52+
The PR body is scanned for issue references in these formats:
53+
54+
- `#123` (same-repo)
55+
- `getsentry/repo#123` (cross-repo)
56+
- `https://github.com/getsentry/repo/issues/123` (full URL)
57+
- With optional keywords: `Fixes #123`, `Closes getsentry/repo#123`, etc.
58+
59+
A PR is valid if **any** referenced issue passes all checks:
60+
- The issue is fetchable and in a `getsentry` repository
61+
- If the issue has assignees, the PR author must be one of them
62+
- Both the PR author and a maintainer have participated in the issue discussion
63+
64+
### Draft enforcement
65+
66+
Non-draft PRs are converted to draft and labeled `converted-to-draft` with an informational comment.
67+
68+
## Labels
69+
70+
The action creates these labels automatically (they don't need to exist beforehand):
71+
72+
- `violating-contribution-guidelines` — added to all closed PRs
73+
- `missing-issue-reference` — PR body has no issue references
74+
- `missing-maintainer-discussion` — referenced issue lacks author + maintainer discussion
75+
- `issue-already-assigned` — referenced issue is assigned to someone else
76+
- `converted-to-draft` — PR was automatically converted to draft

validate-pr/action.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: 'Validate PR'
2+
description: 'Validates non-maintainer PRs against contribution guidelines and enforces draft status'
3+
author: 'Sentry'
4+
5+
inputs:
6+
app-id:
7+
description: 'GitHub App ID for the SDK Maintainer Bot'
8+
required: true
9+
private-key:
10+
description: 'GitHub App private key for the SDK Maintainer Bot'
11+
required: true
12+
13+
outputs:
14+
was-closed:
15+
description: 'Whether the PR was closed by the validation step'
16+
value: ${{ steps.validate.outputs.was-closed }}
17+
18+
runs:
19+
using: 'composite'
20+
steps:
21+
- name: Generate GitHub App token
22+
id: app-token
23+
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
24+
with:
25+
app-id: ${{ inputs.app-id }}
26+
private-key: ${{ inputs.private-key }}
27+
28+
- name: Validate PR
29+
id: validate
30+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
31+
with:
32+
github-token: ${{ steps.app-token.outputs.token }}
33+
script: |
34+
const script = require('${{ github.action_path }}/scripts/validate-pr.js');
35+
await script({ github, context, core });
36+
37+
- name: Convert PR to draft
38+
if: >-
39+
steps.validate.outputs.was-closed != 'true'
40+
&& github.event.pull_request.draft == false
41+
shell: bash
42+
env:
43+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
44+
PR_URL: ${{ github.event.pull_request.html_url }}
45+
run: gh pr ready "$PR_URL" --undo
46+
47+
- name: Label and comment on draft conversion
48+
if: >-
49+
steps.validate.outputs.was-closed != 'true'
50+
&& github.event.pull_request.draft == false
51+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
52+
with:
53+
github-token: ${{ steps.app-token.outputs.token }}
54+
script: |
55+
const script = require('${{ github.action_path }}/scripts/enforce-draft.js');
56+
await script({ github, context, core });
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// @ts-check
2+
3+
/**
4+
* Labels a PR that was converted to draft and leaves an informational comment.
5+
* Skips if a bot comment already exists (to avoid duplicates on reopen).
6+
*
7+
* @param {object} params
8+
* @param {import('@actions/github').getOctokit} params.github
9+
* @param {import('@actions/github').context} params.context
10+
* @param {import('@actions/core')} params.core
11+
*/
12+
module.exports = async ({ github, context, core }) => {
13+
const pullRequest = context.payload.pull_request;
14+
const repo = context.repo;
15+
16+
await github.rest.issues.addLabels({
17+
...repo,
18+
issue_number: pullRequest.number,
19+
labels: ['converted-to-draft'],
20+
});
21+
22+
// Check for existing bot comment to avoid duplicates on reopen
23+
const comments = await github.rest.issues.listComments({
24+
...repo,
25+
issue_number: pullRequest.number,
26+
});
27+
const botComment = comments.data.find(c =>
28+
c.user.type === 'Bot' &&
29+
c.body.includes('automatically converted to draft')
30+
);
31+
if (botComment) {
32+
core.info('Bot comment already exists, skipping.');
33+
return;
34+
}
35+
36+
const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`;
37+
38+
await github.rest.issues.createComment({
39+
...repo,
40+
issue_number: pullRequest.number,
41+
body: [
42+
`This PR has been automatically converted to draft. All PRs must start as drafts per our [contributing guidelines](${contributingUrl}).`,
43+
'',
44+
'**Next steps:**',
45+
'1. Ensure CI passes',
46+
'2. Fill in the PR description completely',
47+
'3. Mark as "Ready for review" when you\'re done',
48+
].join('\n'),
49+
});
50+
};

0 commit comments

Comments
 (0)