Browse Source

Add /update-api and /api-diff commands (#20887)

* Add update-api command

* Api diff command

* Missed flag

* Restrict commands running on fork PRs

* Add concurrency

* Filter github.event.comment.author_association even before workflow started

* Use steps.pr.outputs.sha

* Only push api/ changes
pull/20892/head
Max Katz 2 weeks ago
committed by GitHub
parent
commit
2ffb4d01e0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 180
      .github/workflows/api-diff.yml
  2. 123
      .github/workflows/update-api.yml

180
.github/workflows/api-diff.yml

@ -0,0 +1,180 @@
name: Output API Diff
on:
issue_comment:
types: [created]
permissions: {}
concurrency:
group: api-diff-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
api-diff:
name: Output API Diff
if: >-
github.event.issue.pull_request
&& contains(github.event.comment.body, '/api-diff')
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check maintainer permission
uses: actions/github-script@v7
with:
script: |
const { data: permLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login,
});
const allowed = ['admin', 'maintain', 'write'];
if (!allowed.includes(permLevel.permission)) {
core.setFailed(`User @${context.payload.comment.user.login} does not have write access.`);
}
- name: Add reaction to acknowledge command
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes',
});
- name: Get PR branch info
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
core.setFailed('Cannot run /api-diff on fork PRs — would execute untrusted code.');
return;
}
core.setOutput('ref', pr.head.ref);
core.setOutput('sha', pr.head.sha);
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ steps.pr.outputs.sha }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Run OutputApiDiff
run: dotnet run --project ./nukebuild/_build.csproj -- OutputApiDiff
- name: Post API diff as PR comment
if: always() && steps.pr.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
const diffDir = path.join(process.env.GITHUB_WORKSPACE, 'artifacts', 'api-diff', 'markdown');
const mergedPath = path.join(diffDir, '_diff.md');
let body;
if (fs.existsSync(mergedPath)) {
let diff = fs.readFileSync(mergedPath, 'utf8').trim();
if (!diff || diff.toLowerCase().includes('no changes')) {
body = '### API Diff\n\n✅ No public API changes detected in this PR.';
} else {
const MAX_COMMENT_LENGTH = 60000; // GitHub comment limit is 65536
const header = '### API Diff\n\n';
const footer = '\n\n---\n_Generated by `/api-diff` command._';
const budget = MAX_COMMENT_LENGTH - header.length - footer.length;
if (diff.length > budget) {
diff = diff.substring(0, budget) + '\n\n> ⚠️ Output truncated. See the [full workflow run](' +
`${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` +
') for complete diff.';
}
body = header + diff + footer;
}
} else {
body = '### API Diff\n\n⚠️ No diff output was produced. Check the [workflow run](' +
`${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` +
') for details.';
}
// Collapse into <details> if large
if (body.length > 2000) {
const inner = body;
body = '<details>\n<summary>📋 API Diff (click to expand)</summary>\n\n' + inner + '\n\n</details>';
}
// Update existing bot comment or create a new one
const marker = '<!-- api-diff-bot -->';
body = marker + '\n' + body;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
const existing = comments.find(c => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Add success reaction
if: success()
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket',
});
- name: Report failure
if: failure()
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: '-1',
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `❌ \`/api-diff\` failed. [See logs](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`,
});

123
.github/workflows/update-api.yml

@ -0,0 +1,123 @@
name: Update API Suppressions
on:
issue_comment:
types: [created]
permissions: {}
concurrency:
group: update-api-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
update-api:
name: Update API Suppressions
if: >-
github.event.issue.pull_request
&& contains(github.event.comment.body, '/update-api')
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check maintainer permission
uses: actions/github-script@v7
with:
script: |
const { data: permLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login,
});
const allowed = ['admin', 'maintain', 'write'];
if (!allowed.includes(permLevel.permission)) {
core.setFailed(`User @${context.payload.comment.user.login} does not have write access.`);
}
- name: Add reaction to acknowledge command
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes',
});
- name: Get PR branch info
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
core.setFailed('Cannot run /update-api on fork PRs — would execute untrusted code with write permissions.');
return;
}
core.setOutput('ref', pr.head.ref);
core.setOutput('sha', pr.head.sha);
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ steps.pr.outputs.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Run ValidateApiDiff
run: dotnet run --project ./nukebuild/_build.csproj -- ValidateApiDiff --update-api-suppression true
- name: Commit and push changes
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add api/
if git diff --cached --quiet; then
echo "No API suppression changes to commit."
else
git commit -m "Update API suppressions"
git push origin HEAD:${{ steps.pr.outputs.ref }}
fi
- name: Add success reaction
if: success()
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket',
});
- name: Report failure
if: failure()
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: '-1',
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `❌ \`/update-api\` failed. [See logs](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`,
});
Loading…
Cancel
Save